v0.7.80-beta - Volgende-knop, tijdzones, netwerken per groepje, Pangolin
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m10s

De knop Volgende deed niets. Alpine haalt een boolean-attribuut alleen weg bij
null, undefined of false; het getal 0 zet het juist AAN. Mijn
:disabled="stepMissing().length" gaf bij een ingevuld formulier 0 en zette de
knop dus permanent uit. De logica klopte, de binding niet — met losse logica
niet te vinden, dus er draait nu een test die de echte modal met Alpine in een
DOM rendert en erop klikt.

Tijdzones:
- Nieuwe instelling TIMEZONE, standaard die van de server (TZ, /etc/timezone,
  /etc/localtime). In alle 77 sjablonen stond Europe/Amsterdam hard ingevuld.
- Apps zonder tijdzoneveld krijgen TZ er automatisch bij; die draaiden in UTC.

Poorten:
- _bezette_poorten() telt nu ook mee waar de host zelf op luistert, gelezen uit
  /proc/net/tcp in een hulpcontainer met --network host. Een nginx buiten
  Docker zag Server Up eerder niet.
- GET /api/ports/check geeft vrij/bezet plus een alternatief, en meldt of de
  host echt gecontroleerd kon worden.

Netwerken:
- APP_NETWORKS: meerdere gedeelde bridge-netwerken in plaats van één waar élke
  app aan hing. Per app kies je welke; aanmaken kan vanuit het invulmenu, met
  eigen naam of een voorstel (su-<appnaam>).
- add_shared_network() accepteert een lijst; één naam blijft werken.
- GET /api/networks/<n>/ip-check controleert subnet, bereik, Docker-toewijzing
  en de ARP-tabel van de host (vangt een fysiek apparaat met DHCP-adres) en
  stelt een vrij adres voor.

Pangolin (nieuw, core/pangolin.py):
- Publieke URL per app via een tunnel, zonder poorten open te zetten.
- De API veranderde in 1.9 (resource hing onder een site, staat nu los); beide
  routes worden geprobeerd en bij een fout krijg je beide meldingen.
- API-sleutel is write-only. Getest tegen een nagebouwde API, niet tegen een
  echte server.

Nieuw: docs/pangolin.md; docs/netwerken.md uitgebreid. 2369 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-28 22:25:38 +02:00
parent bc55252267
commit ea9b1eb668
19 changed files with 1956 additions and 60 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ __pycache__/
.env
.pytest_cache/
venv/
node_modules/

View file

@ -1,3 +1,53 @@
# v0.7.80-beta — De knop Volgende, tijdzones, en netwerken per groepje
**De knop Volgende deed niets.** Alpine haalt een boolean-attribuut alleen weg
bij `null`, `undefined` of `false` — het getal `0` zét het juist. Mijn
`:disabled="stepMissing().length"` gaf bij een compleet ingevuld formulier `0`,
en daarmee stond de knop permanent uit. De logica klopte, de binding niet.
Dat was met losse logica niet te vinden, dus er draait nu een test die de echte
modal met Alpine in een DOM rendert en er daadwerkelijk op klikt. Die vangt
precies dit soort bindingsfouten. Hij slaat zichzelf over waar `jsdom` ontbreekt.
**Tijdzones komen van de server.** In alle 77 sjablonen die er een hebben stond
`Europe/Amsterdam` hard ingevuld — dat klopt alleen toevallig. Er is nu een
instelling **Tijdzone** die standaard de tijdzone van je server overneemt (uit
`TZ`, `/etc/timezone` of `/etc/localtime`), en die wordt als voorstel gebruikt.
De negentien apps zonder tijdzoneveld krijgen `TZ` er automatisch bij, want die
draaiden tot nu toe in UTC en zetten dus overal het verkeerde tijdstip neer.
**Poortcontrole kijkt nu ook naar de host.** `docker ps` toont alleen wat Docker
publiceert; een nginx of DNS-server die rechtstreeks op de host draait bleef
onzichtbaar terwijl je er net zo goed mee botst. Die worden nu meegeteld, via
`/proc/net/tcp` in een hulpcontainer met `--network host`. Lukt dat niet, dan
zegt het antwoord `host_checked: false` — "vrij" is dan een aanname.
**Niet alles hoeft meer aan alles vast.** Er was één gedeeld netwerk waar élke
app aan hing. Je kiest nu per app aan welke netwerken hij meedoet, en kunt er bij
het installeren zelf een maken — met een eigen naam of een voorgestelde
(`su-<appnaam>`). Dat netwerk verschijnt daarna bij elke andere app in de lijst.
Een app kan aan meerdere netwerken tegelijk hangen.
**Advies bij IP-adressen.** Vul je een adres in, dan wordt gecontroleerd of het
binnen het subnet valt, binnen je ingestelde bereik, of het al aan een container
is toegekend, en of de host het recent op het LAN heeft gezien — dat laatste uit
de ARP-tabel, en dat vangt een fysiek apparaat met een DHCP-adres. Klopt er iets
niet, dan staat erbij wát en krijg je een vrij adres voorgesteld.
**Nieuw: Pangolin.** Onder Inloggegevens kun je met één klik een publiek adres
voor een app aanmaken via een [Pangolin](https://github.com/fosrl/pangolin)-tunnel,
zonder poorten open te zetten. Zie [`docs/pangolin.md`](docs/pangolin.md).
> Deze integratie is getest tegen een nagebouwde API, niet tegen een echte
> Pangolin-server — die heb ik hier niet. De API veranderde bovendien in
> versie 1.9 (een resource hing eerst onder een site en staat nu los), dus
> beide routes worden geprobeerd en bij een fout krijg je beide meldingen te
> zien.
2369 tests groen.
---
# v0.7.70-beta — Installeren via een invulmenu, geheimen naar .env
**Het installatieformulier is een invulmenu geworden.** Eén lange lijst werkte

View file

@ -1 +1 @@
0.7.70-beta
0.7.80-beta

View file

@ -7,6 +7,7 @@
| [apps-maken.md](apps-maken.md) | Eigen apps toevoegen: het volledige `template.json`-formaat, de compose-syntaxis, apps aan elkaar koppelen en onderdelen optioneel maken. |
| [netwerken.md](netwerken.md) | Stacks een eigen IP-adres geven in je LAN met macvlan of ipvlan. Inclusief de valkuil dat de host zijn eigen macvlan-containers niet bereikt. |
| [backups.md](backups.md) | Wat er wél en niet in een backup zit, terugzetten, bewaarbeleid, geplande backups, controleren, downloaden en een tweede bestemming. |
| [pangolin.md](pangolin.md) | Publieke adressen voor je apps via een Pangolin-tunnel, zonder poorten open te zetten. |
| [meldingen.md](meldingen.md) | Bericht krijgen als er 's nachts iets misgaat: ntfy, webhook of e-mail, en waarover je gemeld wilt worden. |
| [updates.md](updates.md) | Update-kanalen (stable en beta), bijwerken vanuit de interface, en de Forgejo-registry inrichten. |
| [synchroniseren.md](synchroniseren.md) | Code van je eigen apparaat automatisch naar de server krijgen via Forgejo. |

View file

@ -176,3 +176,63 @@ betreffende stacks; Server Up weigert het anders met een melding.
| Adres al in gebruik | Het adres valt binnen je DHCP-bereik; verklein de DHCP-pool of kies een andere range |
| Vanaf de server niet bereikbaar, vanaf laptop wel | Verwacht gedrag — zie de shim-interface bovenaan |
| Werkt niet op WiFi | Klopt: macvlan werkt niet over een draadloze interface |
---
## Gedeelde netwerken tussen apps
Naast een eigen IP-adres is er het gedeelde bridge-netwerk waarop apps elkaar op
containernaam vinden. Tot v0.7.70 was dat er één (`serverup`) en hing élke app
eraan: Vaultwarden kon bij je mediaserver, en andersom.
Nu kies je per app aan welke netwerken hij meedoet, en kun je er bij het
installeren zelf een maken. Laat je de naam leeg, dan wordt er een voorgesteld
(`su-<appnaam>`). Het netwerk verschijnt daarna in de lijst bij elke andere app.
```
Stap 2 — Verbinden
☑ serverup standaard 12 containers
☑ su-media 3 containers
☐ su-beheer 2 containers
[ media ] [+ Netwerk maken]
```
Een app kan aan meerdere netwerken tegelijk hangen — handig voor een reverse
proxy die bij alles moet kunnen.
Verwijderen kan alleen als er geen containers meer aan hangen, en het
standaardnetwerk blijft altijd staan.
---
## Is dit adres nog vrij?
Bij het invullen van een IP-adres controleert Server Up vier dingen:
1. valt het binnen het **subnet** van het netwerk;
2. valt het binnen het ingestelde **bereik** (buiten je DHCP-pool dus);
3. is het al **toegekend aan een container** in dit netwerk of gereserveerd door
een andere stack;
4. heeft de host het adres recent **op het LAN gezien** — dat leest de ARP-tabel
van de host, en vangt een fysiek apparaat met een DHCP-adres.
Klopt er iets niet, dan staat erbij wat en krijg je een vrij adres voorgesteld.
> Punt 4 is geen garantie: een apparaat dat uit staat, staat niet in de
> ARP-tabel. Houd je macvlan-bereik daarom buiten wat je router uitdeelt.
---
## Is deze poort nog vrij?
Poortvelden krijgen automatisch een vrij nummer voorgesteld. Daarbij wordt niet
alleen gekeken naar wat Docker publiceert, maar ook naar wat er **rechtstreeks
op de host luistert** — een nginx of DNS-server buiten Docker zag Server Up
eerder niet, terwijl je er net zo goed mee botst.
Dat laatste gaat via een korte hulpcontainer met `--network host`. Lukt dat
niet, dan meldt het antwoord `host_checked: false`: "vrij" is dan een aanname
op basis van Docker alleen.

78
docs/pangolin.md Normal file
View file

@ -0,0 +1,78 @@
# Pangolin — een publiek adres voor je apps
[Pangolin](https://github.com/fosrl/pangolin) is een reverse proxy met tunnel:
je apps blijven thuis draaien en krijgen toch een adres op internet, zónder dat
je poorten hoeft open te zetten in je router. Server Up kan die adressen voor je
aanmaken, zodat je dat niet per app in de Pangolin-interface hoeft te doen.
> **Server Up installeert Pangolin niet.** Je hebt een draaiende Pangolin-server
> nodig (in de cloud of op een VPS) en een Newt-site die vanaf je thuisnetwerk
> naar buiten verbindt. Server Up praat alleen met de API.
---
## Instellen
Onder **Instellingen → Pangolin**:
| Veld | Waar vind je dat |
|---|---|
| Adres | De URL van je Pangolin-server, bijvoorbeeld `https://pangolin.voorbeeld.nl` |
| API-sleutel | In Pangolin onder **Settings → API Keys**. Geef hem rechten om resources en targets te maken. |
| Organisatie-id | Staat in de URL als je in Pangolin bent ingelogd |
| Site | Wordt opgehaald zodra de sleutel werkt; dit is je Newt-verbinding |
| Domein | Wordt opgehaald zodra de sleutel werkt |
Klik daarna op **Verbinding testen**. Lukt het, dan staat er hoeveel sites er
gevonden zijn. De API-sleutel is write-only: je kunt hem instellen, maar de API
geeft hem nooit terug.
---
## Een URL aanmaken
Open **Inloggegevens** op de stackkaart en klik op **Publieke URL aanmaken**.
Server Up doet dan twee dingen:
1. een *resource* aanmaken — het publieke adres, standaard `<stacknaam>.<domein>`;
2. een *target* aanmaken — waar dat adres naartoe wijst.
Het doel wordt afgeleid: heeft de stack een eigen IP-adres (macvlan), dan dat;
anders het adres van de host met de gepubliceerde poort. De URL wordt bij de
stack bewaard en daarna in het paneel getoond.
---
## Als het misgaat
**"De API-sleutel wordt geweigerd (401)"** — de sleutel is verlopen of heeft te
weinig rechten.
**"Organisatie niet gevonden (404)"** — het organisatie-id klopt niet. Het is de
korte naam uit de URL, niet de weergavenaam.
**"Geen site gevonden; installeer eerst Newt"** — Pangolin heeft een tunnel naar
je netwerk nodig. Zet Newt op je thuisserver en koppel die als site.
**"Aanmaken mislukt"** met twee HTTP-codes — de API van Pangolin is in versie
1.9 veranderd: een resource hing eerst onder een site
(`PUT /org/<org>/site/<site>/resource`) en staat sindsdien los
(`PUT /org/<org>/resource`). Server Up probeert allebei en toont beide
foutmeldingen, zodat je ziet welke route jouw versie verwacht. Zie
[fosrl/pangolin#1344](https://github.com/fosrl/pangolin/issues/1344).
---
## Wat er níét gebeurt
- Server Up beheert de URL niet verder: verwijder je de stack, dan blijft de
resource in Pangolin staan. Ruim die daar zelf op.
- Er wordt geen authenticatie voor de resource ingesteld. Wil je dat het adres
niet voor de hele wereld open staat, zet dan in Pangolin zelf een
toegangsregel of SSO op die resource.
- Meerdere targets per resource (load balancing) valt hierbuiten.
> **Deze integratie is getest tegen een nagebouwde API, niet tegen een echte
> Pangolin-server.** Dat de juiste verzoeken verstuurd worden ligt vast in de
> tests; of jouw versie ze accepteert, blijkt bij de eerste poging. De
> foutmeldingen zijn daarom zo volledig mogelijk gehouden.

View file

@ -1,6 +1,6 @@
"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, secrets, shutil, subprocess, sys, time
import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
from datetime import datetime, timedelta
from pathlib import Path
@ -16,7 +16,7 @@ import core as cfg
from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater
from core import backups, categories, compose_transform, diskspace, networks
from core import notify
from core import notify, pangolin
from core import scheduler, selfupdate
from core import stackupdates
from core.modules import Module, CORE, discover
@ -161,7 +161,8 @@ ADMIN_ONLY_PREFIXES = (
"/api/auth/users", "/api/auth/mode", "/api/settings", "/api/networks",
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
"/api/update/", "/api/audit/clear", "/api/docker/restart",
"/api/docker/images", "/api/notify/",
"/api/docker/images", "/api/notify/", "/api/appnetworks",
"/api/pangolin",
)
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
@ -803,6 +804,7 @@ def api_stack_credentials(name):
audit.log("stacks", "credentials_view", "ok", ref=name,
ip=request.remote_addr)
return jsonify(ok=True, address=adres, items=items,
pangolin_url=meta.get("pangolin_url", ""),
# Apps als Vaultwarden en Immich maken het eerste account aan
# bij het eerste bezoek; dan valt er hier niets te tonen.
self_signup=not items)
@ -920,6 +922,7 @@ def api_stack_config(name):
values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""),
connected=bool(meta.get("connected")),
app_networks=meta.get("app_networks") or [],
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name))
@ -944,10 +947,13 @@ def api_stack_reconfigure(name):
net_name = (body.get("network") if "network" in body else meta.get("network")) or ""
net_ip = (body.get("ip") if "ip" in body else meta.get("ip")) or ""
verbinden = body.get("connect_apps")
if verbinden is None:
verbinden = bool(meta.get("connected"))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
if "app_networks" in body or "connect_apps" in body:
app_netten = _gekozen_app_netwerken(body, gedeeld_net)
else:
app_netten = [n for n in (meta.get("app_networks") or []) if n] or (
[gedeeld_net] if meta.get("connected") else [])
verbinden = bool(app_netten)
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
@ -998,12 +1004,18 @@ def api_stack_reconfigure(name):
cf.read_text(encoding="utf-8"), net_name, net_ip),
encoding="utf-8")
jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}")
if verbinden and gedeeld_net:
ok_net, _ = networks.ensure_shared(gedeeld_net)
if app_netten:
klaar = []
for naam in app_netten:
ok_net, fout_net = networks.ensure_shared(naam)
if ok_net:
klaar.append(naam)
else:
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
if klaar:
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), gedeeld_net), encoding="utf-8")
jobs.log(qq, "ok", f"Gekoppeld aan '{gedeeld_net}'")
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
@ -1017,7 +1029,8 @@ def api_stack_reconfigure(name):
"values": zichtbaar, "secret_fields": geheime_namen,
"secret_keys": secret_keys,
"network": net_name, "ip": net_ip,
"connected": bool(verbinden and gedeeld_net)})
"app_networks": app_netten,
"connected": bool(app_netten)})
docker.compose_down(d, log_fn=lf, name=name)
rc = docker.compose_up(d, log_fn=lf, name=name)
@ -1281,6 +1294,41 @@ def _is_secret_field(f: dict) -> bool:
return any(h in haystack for h in _SECRET_HINTS)
def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
"""Welke gedeelde netwerken wil de gebruiker voor deze stack?
Nieuw is `app_networks` (een lijst). De oude `connect_apps` (aan/uit) blijft
werken zodat bestaande installaties en scripts niet omvallen.
"""
if isinstance(body.get("app_networks"), list):
bekend = {n["name"] for n in networks.app_netwerken()}
return [n for n in body["app_networks"]
if isinstance(n, str) and n in bekend]
verbinden = body.get("connect_apps")
if verbinden is None:
verbinden = bool(cfg.load().get("CONNECT_APPS_DEFAULT", True))
return [standaard] if verbinden else []
def _bezette_poorten() -> set[int]:
"""Alles wat een nieuwe stack in de weg kan zitten.
`docker ps` toont alleen wat Docker publiceert. Draait er een webserver of
een DNS-server rechtstreeks op de host, dan botst je stack daar net zo goed
mee met een foutmelding die niets over de oorzaak zegt.
"""
bezet: set[int] = set()
try:
bezet |= docker.used_ports()
except Exception:
pass
try:
bezet |= networks.host_luisterpoorten()
except Exception:
pass
return bezet
def _annotate_fields(fields: list[dict]) -> list[dict]:
"""Verrijk de installatievelden met een vrije poort en een secret-vlag.
@ -1288,15 +1336,17 @@ def _annotate_fields(fields: list[dict]) -> list[dict]:
installatie niet strandt op 'port is already allocated';
- geheime velden worden gemarkeerd zodat de UI een genereerknop toont.
"""
try:
bezet = docker.used_ports()
except Exception:
bezet = set()
bezet = _bezette_poorten()
tz = cfg.tijdzone()
uitgedeeld: set[int] = set()
for f in fields:
if _is_secret_field(f):
f["secret"] = True
naam = (f.get("name") or "").lower()
# De sjablonen hebben Europe/Amsterdam hard ingevuld staan; dat klopt
# alleen toevallig. De tijdzone van deze server is een beter voorstel.
if naam in ("timezone", "tz") and f.get("type") != "int":
f["default"] = tz
if f.get("type") == "int" and "port" in naam:
try:
gewenst = int(f.get("default") or 0)
@ -1466,10 +1516,11 @@ def api_store_install():
net_ip = (d.get("ip") or "").strip()
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
# kan bereiken (en andersom).
verbinden = d.get("connect_apps")
if verbinden is None:
verbinden = bool(cfg.load().get("CONNECT_APPS_DEFAULT", True))
# Aan welke gedeelde netwerken deze stack meedoet. Eerder was dit één
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
verbinden = bool(app_netten)
if not stack:
return jsonify(ok=False, msg="stack vereist")
@ -1576,9 +1627,18 @@ def api_store_install():
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error"); jobs.done(qq); return
# Tijdzone: apps zonder eigen veld draaien anders in UTC.
cf_render = docker.find_compose(dest)
if cf_render and cfg.load().get("TIMEZONE_ALL_APPS", True):
try:
cf_render.write_text(compose_transform.zorg_voor_tijdzone(
cf_render.read_text(encoding="utf-8"), cfg.tijdzone()),
encoding="utf-8")
except compose_transform.TransformError:
pass
# Wachtwoorden en sleutels naar .env, zodat het compose-bestand
# te delen is en de geheimen op één plek met 0600 staan.
cf_render = docker.find_compose(dest)
if cf_render:
try:
nieuw, env_geheim, koppeling = compose_transform.geheimen_naar_env(
@ -1675,21 +1735,24 @@ def api_store_install():
# Aan het gedeelde netwerk hangen. Zonder dit zit elke stack in
# zijn eigen compose-netwerk en vindt geen enkele app een andere.
if verbinden and gedeeld_net:
ok_net, fout_net = networks.ensure_shared(gedeeld_net)
if not ok_net:
jobs.log(qq, "warn",
f"Gedeeld netwerk '{gedeeld_net}' niet beschikbaar: "
f"{fout_net} — de stack start zonder koppeling.")
if app_netten:
klaar = []
for naam in app_netten:
ok_net, fout_net = networks.ensure_shared(naam)
if ok_net:
klaar.append(naam)
else:
jobs.log(qq, "warn",
f"Netwerk '{naam}' niet beschikbaar: {fout_net}"
"de stack start zonder die koppeling.")
if klaar:
cf = docker.find_compose(dest)
try:
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), gedeeld_net),
encoding="utf-8")
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok",
f"Gekoppeld aan netwerk '{gedeeld_net}' — andere "
"apps zijn bereikbaar op hun containernaam.")
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
+ "apps daarin zijn bereikbaar op hun containernaam.")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
@ -1727,7 +1790,8 @@ def api_store_install():
# geen andere installatie hetzelfde IP voorgesteld.
"network": net_name,
"ip": net_ip if net else "",
"connected": bool(verbinden and gedeeld_net),
"connected": bool(app_netten),
"app_networks": app_netten,
"categories": meta.get("categories") or [],
"tags": meta.get("tags") or [],
# Zonder de gebruikte waarden valt het installatieformulier
@ -1975,6 +2039,210 @@ def api_disk():
return jsonify(diskspace.status())
@app.route("/api/pangolin")
def api_pangolin():
"""Instellingen plus, als het werkt, de sites en domeinen om uit te kiezen."""
i = pangolin.instellingen()
uit = {"url": i["url"], "org": i["org"], "site": i["site"],
"domain": i["domain"], "has_key": bool(i["key"]),
"configured": pangolin.ingesteld(), "sites": [], "domains": [],
"error": ""}
if uit["configured"]:
try:
uit["sites"] = pangolin.sites()
uit["domains"] = pangolin.domeinen()
except pangolin.PangolinError as e:
uit["error"] = str(e)
return jsonify(uit)
@app.route("/api/pangolin/test", methods=["POST"])
def api_pangolin_test():
ok, msg = pangolin.test()
audit.log("pangolin", "test", "ok" if ok else "error", detail=msg[:200],
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/stacks/<name>/pangolin", methods=["POST"])
def api_stack_pangolin(name):
"""Maak een publieke URL voor deze stack aan in Pangolin."""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
if not pangolin.ingesteld():
return jsonify(ok=False, msg="Pangolin is nog niet ingesteld."), 400
body = request.json or {}
meta = _stack_meta_lezen(d)
# Waar wijst de URL naartoe? Een eigen IP-adres, anders de host op de
# gepubliceerde poort.
ip = (body.get("ip") or meta.get("ip") or "").strip()
poort = body.get("port")
if not poort:
try:
poorten = compose_transform.published_ports(docker.read_compose(d))
eerste = str(poorten[0]).strip('"').replace("/tcp", "").split(":")
poort = int(eerste[-2] if len(eerste) >= 2 else eerste[0])
except Exception:
poort = 0
if not ip:
ip = (body.get("host_ip") or "").strip() or _eigen_host_ip()
if not ip or not poort:
return jsonify(ok=False,
msg="Kan niet bepalen waar deze stack draait; vul het "
"adres en de poort zelf in."), 400
try:
res = pangolin.publiceer(
naam=body.get("name") or name,
subdomein=(body.get("subdomain") or name).strip().lower(),
ip=ip, poort=int(poort),
site_id=body.get("site_id"), domein_id=body.get("domain_id", ""))
except pangolin.PangolinError as e:
audit.log("pangolin", "publish", "error", ref=name, detail=str(e)[:200])
return jsonify(ok=False, msg=str(e)), 400
_stack_meta_schrijven(d, {"pangolin_url": res.get("url", ""),
"pangolin_resource": res.get("resource_id")})
audit.log("pangolin", "publish", "ok", ref=name,
detail=res.get("url", "")[:200], ip=request.remote_addr)
return jsonify(ok=True, **res)
def _eigen_host_ip() -> str:
"""Adres waarop de host bereikbaar is, voor het doel van een tunnel."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return ""
@app.route("/api/appnetworks")
def api_app_networks():
"""Gedeelde netwerken waar apps aan gekoppeld kunnen worden."""
return jsonify(networks=networks.app_netwerken(),
suggested=networks.voorstel_netwerknaam(
request.args.get("for", "")))
@app.route("/api/appnetworks", methods=["POST"])
def api_app_network_create():
"""Maak een gedeeld netwerk aan.
Zonder naam verzinnen we er een, zodat je niet hoeft na te denken over iets
wat toch alleen intern gebruikt wordt.
"""
d = request.json or {}
naam = (d.get("name") or "").strip()
if not naam:
naam = networks.voorstel_netwerknaam(d.get("for", ""))
ok, fout = networks.app_netwerk_toevoegen(naam)
audit.log("networks", "app_create", "ok" if ok else "error", ref=naam,
detail=fout[:200], ip=request.remote_addr)
if not ok:
return jsonify(ok=False, msg=fout), 400
return jsonify(ok=True, name=naam, networks=networks.app_netwerken())
@app.route("/api/appnetworks/<name>", methods=["DELETE"])
def api_app_network_delete(name):
ok, fout = networks.app_netwerk_verwijderen(name)
audit.log("networks", "app_delete", "ok" if ok else "error", ref=name,
detail=fout[:200], ip=request.remote_addr)
return jsonify(ok=ok, msg=fout), (200 if ok else 400)
@app.route("/api/ports/check")
def api_port_check():
"""Is deze poort vrij, en zo nee: welke wel?
Kijkt naar wat Docker publiceert én naar wat er rechtstreeks op de host
luistert; dat laatste zag Server Up eerder niet.
"""
try:
poort = int(request.args.get("port") or 0)
except ValueError:
poort = 0
if not 1 <= poort <= 65535:
return jsonify(ok=False, msg="Ongeldige poort"), 400
bezet = _bezette_poorten()
door = ""
if poort in bezet:
try:
door = next((c["name"] for c in docker.list_containers(alle=False)
if f":{poort}->" in (c.get("ports") or "")), "")
except Exception:
door = ""
vrij = poort not in bezet
voorstel = poort
while voorstel in bezet:
voorstel += 1
return jsonify(ok=True, port=poort, free=vrij, suggested=voorstel,
used_by=door,
# Zonder de hulpcontainer zien we alleen Docker; dan is
# "vrij" een aanname en dat moet de melding zeggen.
host_checked=bool(networks.host_luisterpoorten()))
@app.route("/api/networks/<name>/ip-check")
def api_ip_check(name):
"""Is dit adres bruikbaar binnen dit netwerk?
Drie bronnen: het subnet en bereik uit de instellingen, de adressen die
Docker al heeft uitgedeeld, en wat de host recent op het LAN heeft gezien.
Dat laatste vangt een fysiek apparaat met een DHCP-adres precies de
botsing die pas weken later opvalt.
"""
net = _network_by_name(name)
if not net:
return jsonify(ok=False, msg=f"Netwerk '{name}' bestaat niet"), 404
ip = (request.args.get("ip") or "").strip()
if not ip:
vrij = networks.next_free_ip(net, _reserved_ips())
return jsonify(ok=True, ip="", suggested=vrij,
msg="" if vrij else "Geen vrij adres gevonden in dit bereik")
if not networks.ip_in_network(ip, net):
return jsonify(ok=True, ip=ip, free=False, in_subnet=False,
suggested=networks.next_free_ip(net, _reserved_ips()),
reason=f"{ip} valt buiten het subnet {net.get('subnet')}")
redenen = []
try:
if ip in networks.used_ips(name):
redenen.append("al toegekend aan een container in dit netwerk")
except Exception:
pass
if ip in _reserved_ips():
redenen.append("gereserveerd door een andere stack in Server Up")
if ip == (net.get("gateway") or "").strip():
redenen.append("dit is het adres van de gateway")
try:
if ip in networks.buren_op_het_lan():
redenen.append("een apparaat op je netwerk gebruikt dit adres")
except Exception:
pass
bereik = net.get("ip_range") or ""
buiten_bereik = bool(bereik) and not networks.ip_in_network(
ip, {"subnet": bereik})
if buiten_bereik:
redenen.append(f"valt buiten het ingestelde bereik {bereik}")
return jsonify(ok=True, ip=ip, free=not redenen, in_subnet=True,
reason="; ".join(redenen),
suggested="" if not redenen
else networks.next_free_ip(net, _reserved_ips()))
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
def _network_by_name(name: str) -> dict | None:
@ -2425,7 +2693,11 @@ _SETTINGS_HIDE = {"WIZARD_DONE", "ACTIVE_MODULES"}
@app.route("/api/settings")
def api_settings():
c = cfg.redact(cfg.load())
return jsonify({k: v for k, v in c.items() if k not in _SETTINGS_HIDE})
uit = {k: v for k, v in c.items() if k not in _SETTINGS_HIDE}
# Wat de server zelf gebruikt als er geen tijdzone is ingesteld; de
# interface toont dat als placeholder.
uit["host_timezone"] = cfg.host_timezone()
return jsonify(uit)
@app.route("/api/settings", methods=["PUT"])
@ -2445,6 +2717,12 @@ def api_settings_put():
if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS:
return jsonify(ok=False, msg="Onbekend update-kanaal"), 400
if "TIMEZONE" in updates:
tz = str(updates["TIMEZONE"] or "").strip()
if tz and not cfg.geldige_tijdzone(tz):
return jsonify(ok=False,
msg="Ongeldige tijdzone; verwacht iets als Europe/Amsterdam"), 400
updates["TIMEZONE"] = tz
if "NOTIFY_CHANNEL" in updates:
if (updates.get("NOTIFY_CHANNEL") or "") not in notify.KANALEN:
return jsonify(ok=False, msg="Onbekend meldingskanaal"), 400

View file

@ -30,6 +30,13 @@ DEFAULTS = {
"ACTIVE_MODULES": None,
"MODULE_SETTINGS": {},
"LANGUAGE": "nl",
# Tijdzone voor nieuwe apps. Leeg = die van deze server overnemen; zie
# host_timezone(). Zonder dit stond in elk sjabloon Europe/Amsterdam vast,
# ongeacht waar de server staat.
"TIMEZONE": "",
# Apps zonder eigen tijdzoneveld krijgen TZ mee, anders draaien ze in UTC
# en kloppen alle tijdstempels net niet.
"TIMEZONE_ALL_APPS": True,
"THEME": "dark",
"WIZARD_DONE": False,
# Update-check: Forgejo/GitHub Releases-API die de laatste release teruggeeft.
@ -61,6 +68,10 @@ DEFAULTS = {
# geen andere app bereiken.
"SHARED_NETWORK": "serverup",
"CONNECT_APPS_DEFAULT": True,
# Extra bridge-netwerken die je zelf hebt aangemaakt. Eén netwerk voor
# alles betekent dat elke app elke andere kan bereiken; met meerdere kun je
# groepjes maken (media onderling, beheer apart).
"APP_NETWORKS": [],
# Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup
# van een stack wordt nooit opgeruimd.
"BACKUP_KEEP": 5,
@ -96,6 +107,12 @@ DEFAULTS = {
"NOTIFY_EMAIL_FROM": "",
"NOTIFY_EMAIL_TO": "",
"NOTIFY_EMAIL_SECURITY": "starttls", # starttls | ssl | geen
# Pangolin: publieke URL's voor je apps via een tunnel. Leeg = uit.
"PANGOLIN_URL": "",
"PANGOLIN_API_KEY": "",
"PANGOLIN_ORG_ID": "",
"PANGOLIN_SITE_ID": "",
"PANGOLIN_DOMAIN_ID": "",
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
"SCHEDULER_LAST": {},
# Updatecheck voor de geïnstalleerde stacks (image-digests).
@ -109,7 +126,7 @@ SECRET_KEYS = frozenset({"AUTH"})
# Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan
# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens.
WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN", "NOTIFY_TOKEN",
"NOTIFY_EMAIL_PASSWORD"})
"NOTIFY_EMAIL_PASSWORD", "PANGOLIN_API_KEY"})
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
_lock = threading.RLock()
@ -143,6 +160,47 @@ def _sanitize_repos(repos: list) -> list:
return repos
_TZ_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$")
def geldige_tijdzone(tz: str) -> bool:
"""Vorm van een IANA-zone: Europe/Amsterdam, UTC, America/Argentina/Salta."""
return bool(tz) and len(tz) <= 64 and bool(_TZ_RE.match(tz))
def host_timezone() -> str:
"""De tijdzone van deze machine.
Volgorde: de omgevingsvariabele TZ (die zet je in docker-compose mee),
/etc/timezone, en anders waar /etc/localtime naartoe wijst. Lukt niets, dan
UTC dat is beter dan een tijdzone verzinnen.
"""
tz = (os.environ.get("TZ") or "").strip()
if geldige_tijdzone(tz):
return tz
try:
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
if geldige_tijdzone(tz):
return tz
except OSError:
pass
try:
doel = os.path.realpath("/etc/localtime")
if "/zoneinfo/" in doel:
tz = doel.split("/zoneinfo/", 1)[1]
if geldige_tijdzone(tz):
return tz
except OSError:
pass
return "UTC"
def tijdzone() -> str:
"""De ingestelde tijdzone, of die van de server als er niets is gekozen."""
gekozen = (load().get("TIMEZONE") or "").strip()
return gekozen if geldige_tijdzone(gekozen) else host_timezone()
def safe_name(name: str) -> str | None:
"""Valideer een stack-/instantienaam. Retourneert de naam of None.

View file

@ -72,8 +72,11 @@ def to_dedicated_ip(compose_text: str, network: str, ip: str,
allow_unicode=True, width=120)
def add_shared_network(compose_text: str, network: str) -> str:
"""Hang elke service ook aan een gedeeld netwerk.
def add_shared_network(compose_text: str, network) -> str:
"""Hang elke service ook aan een of meer gedeelde netwerken.
`network` mag een naam zijn of een lijst namen. Meerdere netwerken maken
groepjes mogelijk: media-apps onderling, en de rest daarbuiten.
Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken een
@ -91,6 +94,11 @@ def add_shared_network(compose_text: str, network: str) -> str:
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
raise TransformError("compose bevat geen services")
namen = [network] if isinstance(network, str) else list(network or [])
namen = [n for n in namen if n]
if not namen:
return compose_text
aangesloten = 0
for svc in doc["services"].values():
if not isinstance(svc, dict):
@ -103,14 +111,15 @@ def add_shared_network(compose_text: str, network: str) -> str:
if svc.get("network_mode"):
continue
# `networks:` mag een lijst of een mapping zijn; allebei aanvullen.
for naam in namen:
huidig = svc.get("networks")
if huidig is None:
svc["networks"] = [network]
svc["networks"] = [naam]
elif isinstance(huidig, list):
if network not in huidig:
huidig.append(network)
if naam not in huidig:
huidig.append(naam)
elif isinstance(huidig, dict):
huidig.setdefault(network, None)
huidig.setdefault(naam, None)
aangesloten += 1
# Sluit geen enkele service aan, dan hoeft het netwerk ook niet gedeclareerd
@ -120,13 +129,54 @@ def add_shared_network(compose_text: str, network: str) -> str:
netwerken = doc.get("networks")
if not isinstance(netwerken, dict):
netwerken = {}
netwerken.setdefault(network, {"external": True})
for naam in namen:
netwerken.setdefault(naam, {"external": True})
doc["networks"] = netwerken
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True, width=120)
def zorg_voor_tijdzone(compose_text: str, tz: str) -> str:
"""Geef elke service een TZ mee als die er nog geen heeft.
Negentien van de zesennegentig sjablonen hebben geen tijdzoneveld; die
containers draaien dan in UTC en al hun tijdstempels staan er een paar uur
naast. TZ is voor vrijwel elk image de manier om dat te zetten.
"""
if not tz:
return compose_text
try:
doc = yaml.safe_load(compose_text)
except yaml.YAMLError as e:
raise TransformError(f"compose is geen geldige YAML: {e}") from e
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
return compose_text
gewijzigd = False
for svc in doc["services"].values():
if not isinstance(svc, dict):
continue
omgeving = svc.get("environment")
if isinstance(omgeving, dict):
if "TZ" not in omgeving:
omgeving["TZ"] = tz
gewijzigd = True
elif isinstance(omgeving, list):
if not any(isinstance(r, str) and r.split("=")[0].strip() == "TZ"
for r in omgeving):
omgeving.append(f"TZ={tz}")
gewijzigd = True
else:
svc["environment"] = [f"TZ={tz}"]
gewijzigd = True
if not gewijzigd:
return compose_text
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True, width=120)
def geheimen_naar_env(compose_text: str, velden: list[dict],
waarden: dict) -> tuple[str, dict[str, str], dict[str, str]]:
"""Haal wachtwoorden en sleutels uit het compose-bestand naar `.env`.

View file

@ -16,6 +16,7 @@ import socket
import struct
import time
import core as cfg
from core import docker
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
@ -213,6 +214,79 @@ def host_interfaces() -> list[str]:
return [n["interface"] for n in detect_host_networks().get("networks", [])]
def _op_de_host(*pad: str, timeout: int = 20) -> str:
"""Lees een bestand uit de netwerknamespace van de host.
Zelfde truc als bij de routetabel: een korte container met `--network host`.
"""
image = _helper_image()
if not image:
return ""
r = docker._run([docker._bin(), "run", "--rm", "--network", "host",
"--entrypoint", "cat", image, *pad], timeout=timeout)
return r.stdout if r.returncode == 0 else ""
def _poorten_uit_proc(tekst: str) -> set[int]:
"""Luisterende TCP-poorten uit /proc/net/tcp(6).
Kolom `local_address` is HEX:HEX en `st` is 0A voor LISTEN.
"""
poorten: set[int] = set()
for regel in tekst.splitlines()[1:]:
velden = regel.split()
if len(velden) < 4 or velden[3].upper() != "0A":
continue
try:
poorten.add(int(velden[1].rsplit(":", 1)[1], 16))
except (ValueError, IndexError):
continue
return poorten
_POORT_CACHE: tuple[float, set[int]] = (0.0, set())
_POORT_TTL = 20
def host_luisterpoorten(force: bool = False) -> set[int]:
"""Poorten waarop de host zelf luistert.
`docker ps` toont alleen wat Docker publiceert. Een nginx of een DNS-server
die rechtstreeks op de host draait blijft daar onzichtbaar, terwijl je er
net zo goed mee botst: 'port is already allocated'.
"""
global _POORT_CACHE
ts, waarde = _POORT_CACHE
if not force and waarde and (time.time() - ts) < _POORT_TTL:
return set(waarde)
poorten: set[int] = set()
for pad in ("/proc/net/tcp", "/proc/net/tcp6"):
poorten |= _poorten_uit_proc(_op_de_host(pad, timeout=15))
if poorten:
_POORT_CACHE = (time.time(), set(poorten))
return poorten
def _arp_tabel(tekst: str) -> set[str]:
"""IP-adressen uit /proc/net/arp waarvan de host een MAC kent."""
uit = set()
for regel in tekst.splitlines()[1:]:
velden = regel.split()
# IP HW type Flags HWaddress Mask Device — flags 0x0 = onvolledig
if len(velden) >= 4 and velden[2] != "0x0" and velden[3] != "00:00:00:00:00:00":
uit.add(velden[0])
return uit
def buren_op_het_lan() -> set[str]:
"""Adressen die de host recent op het LAN heeft gezien.
Vangt fysieke apparaten met een DHCP-adres die staan niet in Docker en
zijn precies de reden dat een macvlan-adres later ineens botst.
"""
return _arp_tabel(_op_de_host("/proc/net/arp", timeout=15))
# ── IP-toewijzing ────────────────────────────────────────────────────────────
def next_free_ip(net: dict, extra_used: set[str] | None = None) -> str:
@ -525,6 +599,78 @@ def ensure_shared(name: str) -> tuple[bool, str]:
return True, ""
def app_netwerken() -> list[dict]:
"""De bridge-netwerken waar apps aan gekoppeld kunnen worden.
Eén gedeeld netwerk voor álles betekent dat elke app elke andere kan
bereiken. Vaak wil je juist groepjes: media-apps onderling, en de rest
daarbuiten. Dit is de lijst die de interface aanbiedt.
"""
c = cfg.load()
standaard = (c.get("SHARED_NETWORK") or "serverup").strip()
namen = [standaard] + [n for n in (c.get("APP_NETWORKS") or [])
if isinstance(n, str) and n != standaard]
uit = []
for naam in namen:
if not _NAME_RE.match(naam):
continue
uit.append({"name": naam, "exists": exists(naam),
"default": naam == standaard,
"containers": len(_containers_in(naam))})
return uit
def _containers_in(naam: str) -> dict:
try:
return inspect(naam).get("Containers") or {}
except Exception:
return {}
def voorstel_netwerknaam(basis: str = "") -> str:
"""Een vrije naam voorstellen, zodat je er zelf geen hoeft te verzinnen."""
bestaand = {n["name"] for n in app_netwerken()}
kaal = "".join(ch if ch.isalnum() or ch in "-_" else "-"
for ch in (basis or "net").lower()).strip("-_") or "net"
kandidaat = f"su-{kaal}"
if kandidaat not in bestaand and not exists(kandidaat):
return kandidaat
n = 2
while f"{kandidaat}-{n}" in bestaand or exists(f"{kandidaat}-{n}"):
n += 1
return f"{kandidaat}-{n}"
def app_netwerk_toevoegen(naam: str) -> tuple[bool, str]:
"""Maak een bridge-netwerk aan en onthoud dat het er is."""
naam = (naam or "").strip()
if not _NAME_RE.match(naam):
return False, "Ongeldige netwerknaam."
ok, fout = ensure_shared(naam)
if not ok:
return False, fout
c = cfg.load()
lijst = [n for n in (c.get("APP_NETWORKS") or []) if isinstance(n, str)]
if naam not in lijst and naam != (c.get("SHARED_NETWORK") or "serverup"):
cfg.patch({"APP_NETWORKS": lijst + [naam]})
return True, ""
def app_netwerk_verwijderen(naam: str) -> tuple[bool, str]:
"""Haal een netwerk uit de lijst, en uit Docker als er niets meer in zit."""
c = cfg.load()
if naam == (c.get("SHARED_NETWORK") or "serverup"):
return False, "Het standaardnetwerk kan niet verwijderd worden."
if _containers_in(naam):
return False, ("Er hangen nog containers aan dit netwerk; koppel die "
"eerst los.")
cfg.patch({"APP_NETWORKS": [n for n in (c.get("APP_NETWORKS") or [])
if n != naam]})
if exists(naam):
return remove(naam)
return True, ""
def remove(name: str) -> tuple[bool, str]:
if not _NAME_RE.match(name or ""):
return False, "Ongeldige netwerknaam."

206
server-up/core/pangolin.py Normal file
View file

@ -0,0 +1,206 @@
"""Pangolin: een publieke URL voor een geïnstalleerde app aanmaken.
Pangolin (fosrl/pangolin) is een reverse proxy met tunnel: je apps blijven thuis
draaien en krijgen toch een adres op internet, zonder poorten open te zetten in
je router. De integratie-API kan die adressen aanmaken, wat het handmatige werk
in de webinterface scheelt.
De API zit op `/v1` en werkt met `Authorization: Bearer <sleutel>`.
**Let op de versieverschillen.** Tot 1.9 hing een resource onder een site
(`PUT /org/<org>/site/<site>/resource`); daarna staat de resource los en komt de
site pas bij het target erbij (`PUT /org/<org>/resource`). Beide routes worden
hier geprobeerd, want welke werkt hangt af van de versie die jij draait.
Zie fosrl/pangolin#1344.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
import core as cfg
TIMEOUT = 15
class PangolinError(Exception):
pass
def instellingen() -> dict:
c = cfg.load()
return {
"url": (c.get("PANGOLIN_URL") or "").strip().rstrip("/"),
"key": (c.get("PANGOLIN_API_KEY") or "").strip(),
"org": (c.get("PANGOLIN_ORG_ID") or "").strip(),
"site": c.get("PANGOLIN_SITE_ID") or "",
"domain": (c.get("PANGOLIN_DOMAIN_ID") or "").strip(),
}
def ingesteld() -> bool:
i = instellingen()
return bool(i["url"] and i["key"] and i["org"])
def _verzoek(methode: str, pad: str, body: dict | None = None) -> tuple[int, dict]:
i = instellingen()
if not i["url"] or not i["key"]:
raise PangolinError("Pangolin is niet ingesteld.")
url = f"{i['url']}/v1{pad}"
data = json.dumps(body).encode() if body is not None else None
verzoek = urllib.request.Request(
url, data=data, method=methode,
headers={"Authorization": f"Bearer {i['key']}",
"Content-Type": "application/json",
"Accept": "application/json"})
try:
with urllib.request.urlopen(verzoek, timeout=TIMEOUT) as a:
ruw = a.read().decode(errors="replace")
return a.status, (json.loads(ruw) if ruw.strip() else {})
except urllib.error.HTTPError as e:
ruw = (e.read() or b"").decode(errors="replace")
try:
return e.code, json.loads(ruw)
except ValueError:
return e.code, {"message": ruw[:300]}
except Exception as e: # netwerk, DNS, TLS, timeout
raise PangolinError(f"{type(e).__name__}: {e}") from e
def _melding(antwoord: dict) -> str:
for sleutel in ("message", "error", "msg", "detail"):
if isinstance(antwoord.get(sleutel), str):
return antwoord[sleutel]
return json.dumps(antwoord)[:300]
def _lijst(antwoord, *namen: str) -> list:
"""Pangolin verpakt lijsten wisselend in `data`; allebei aankunnen.
Het antwoord zelf mag ook al een lijst zijn.
"""
if isinstance(antwoord, list):
return antwoord
if not isinstance(antwoord, dict):
return []
bron = antwoord.get("data") if isinstance(antwoord.get("data"), (dict, list)) else antwoord
if isinstance(bron, list):
return bron
if isinstance(bron, dict):
for naam in namen:
if isinstance(bron.get(naam), list):
return bron[naam]
return []
# ── Ophalen ──────────────────────────────────────────────────────────────────
def test() -> tuple[bool, str]:
"""Werkt de verbinding en de sleutel?"""
i = instellingen()
if not i["url"] or not i["key"]:
return False, "Vul eerst het adres en de API-sleutel in."
if not i["org"]:
return False, "Vul de organisatie-id in (staat in de URL van Pangolin)."
try:
code, antwoord = _verzoek("GET", f"/org/{urllib.parse.quote(i['org'])}/sites")
except PangolinError as e:
return False, str(e)
if code == 401:
return False, "De API-sleutel wordt geweigerd (401)."
if code == 404:
return False, f"Organisatie '{i['org']}' niet gevonden (404)."
if code >= 400:
return False, f"HTTP {code}: {_melding(antwoord)}"
n = len(_lijst(antwoord, "sites"))
return True, f"Verbonden — {n} site(s) gevonden."
def sites() -> list[dict]:
i = instellingen()
code, antwoord = _verzoek("GET", f"/org/{urllib.parse.quote(i['org'])}/sites")
if code >= 400:
raise PangolinError(f"Sites ophalen mislukt (HTTP {code}): {_melding(antwoord)}")
uit = []
for s in _lijst(antwoord, "sites"):
if isinstance(s, dict):
uit.append({"id": s.get("siteId") or s.get("id"),
"name": s.get("name") or s.get("niceId") or "?"})
return uit
def domeinen() -> list[dict]:
i = instellingen()
code, antwoord = _verzoek("GET", f"/org/{urllib.parse.quote(i['org'])}/domains")
if code >= 400:
raise PangolinError(f"Domeinen ophalen mislukt (HTTP {code}): {_melding(antwoord)}")
uit = []
for d in _lijst(antwoord, "domains"):
if isinstance(d, dict):
uit.append({"id": d.get("domainId") or d.get("id"),
"domain": d.get("baseDomain") or d.get("domain") or "?"})
return uit
# ── Publiceren ───────────────────────────────────────────────────────────────
def _resource_id(antwoord: dict):
bron = antwoord.get("data") if isinstance(antwoord.get("data"), dict) else antwoord
return bron.get("resourceId") or bron.get("id")
def publiceer(naam: str, subdomein: str, ip: str, poort: int,
site_id=None, domein_id: str = "") -> dict:
"""Maak een publieke URL die naar `ip:poort` wijst.
Twee stappen: eerst de resource (het adres), dan het target (waar het
naartoe wijst). Retourneert {"url": ..., "resource_id": ...}.
"""
i = instellingen()
org = urllib.parse.quote(i["org"])
site = site_id if site_id not in (None, "") else i["site"]
domein = domein_id or i["domain"]
if not domein:
beschikbaar = domeinen()
if not beschikbaar:
raise PangolinError("Geen domein gevonden in Pangolin.")
domein = beschikbaar[0]["id"]
if site in (None, ""):
beschikbaar = sites()
if not beschikbaar:
raise PangolinError("Geen site gevonden in Pangolin; installeer eerst Newt.")
site = beschikbaar[0]["id"]
lichaam = {"name": naam, "http": True, "protocol": "tcp",
"domainId": domein, "subdomain": subdomein or naam}
# Vanaf 1.9 staat de resource los van de site.
code, antwoord = _verzoek("PUT", f"/org/{org}/resource", lichaam)
if code >= 400:
# Oudere versies willen de site in het pad én siteId in de body.
code2, antwoord2 = _verzoek(
"PUT", f"/org/{org}/site/{site}/resource", {**lichaam, "siteId": site})
if code2 >= 400:
raise PangolinError(
f"Aanmaken mislukt (HTTP {code}: {_melding(antwoord)}; "
f"oudere route HTTP {code2}: {_melding(antwoord2)})")
code, antwoord = code2, antwoord2
rid = _resource_id(antwoord)
if not rid:
raise PangolinError("Pangolin gaf geen resource-id terug.")
code, doel = _verzoek("PUT", f"/resource/{rid}/target",
{"siteId": site, "ip": ip, "port": int(poort),
"method": "http", "enabled": True})
if code >= 400:
raise PangolinError(f"Doel instellen mislukt (HTTP {code}): {_melding(doel)}")
bron = antwoord.get("data") if isinstance(antwoord.get("data"), dict) else antwoord
url = bron.get("fullDomain") or bron.get("url") or ""
if url and not url.startswith("http"):
url = f"https://{url}"
return {"url": url, "resource_id": rid, "site_id": site}

View file

@ -883,6 +883,55 @@ tailwind.config = {
</div>
</div>
<!-- Pangolin: publieke URL's zonder poorten open te zetten -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-earth text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Pangolin</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('pangolin_intro')"></p>
<div class="grid sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="form-label" x-text="t('pangolin_url')">Adres</label>
<input class="form-input" placeholder="https://pangolin.voorbeeld.nl"
x-model="settings.PANGOLIN_URL" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('pangolin_key')">API-sleutel</label>
<input type="password" class="form-input"
:placeholder="pangolin.has_key ? '••••••••' : ''"
x-model="settings.PANGOLIN_API_KEY" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('pangolin_org')">Organisatie-id</label>
<input class="form-input" x-model="settings.PANGOLIN_ORG_ID" @change="autoSave()">
</div>
<div x-show="pangolin.sites && pangolin.sites.length">
<label class="form-label" x-text="t('pangolin_site')">Site</label>
<select class="form-input" x-model="settings.PANGOLIN_SITE_ID" @change="autoSave()">
<template x-for="s in pangolin.sites" :key="s.id">
<option :value="s.id" x-text="s.name"></option>
</template>
</select>
</div>
<div x-show="pangolin.domains && pangolin.domains.length">
<label class="form-label" x-text="t('pangolin_domain')">Domein</label>
<select class="form-input" x-model="settings.PANGOLIN_DOMAIN_ID" @change="autoSave()">
<template x-for="d in pangolin.domains" :key="d.id">
<option :value="d.id" x-text="d.domain"></option>
</template>
</select>
</div>
</div>
<p x-show="pangolin.error" class="text-[11px] text-wr" x-text="pangolin.error"></p>
<button class="btn" @click="testPangolin()" :disabled="pangolinTesting">
<span class="mdi" :class="pangolinTesting ? 'mdi-loading mdi-spin' : 'mdi-lan-connect'"></span>
<span x-text="t('test_connection')">Verbinding testen</span>
</button>
</div>
</div>
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
@ -1032,6 +1081,19 @@ tailwind.config = {
<div><label class="form-label" x-text="t('library_dir')">Library map</label><input class="form-input" x-model="settings.LIBRARY_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('data_dir')">Data map</label><input class="form-input" x-model="settings.DATA_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('backup_dir')">Backup map</label><input class="form-input" x-model="settings.BACKUP_DIR" @change="autoSave()"></div>
<div>
<label class="form-label" x-text="t('timezone')">Tijdzone</label>
<input class="form-input" x-model="settings.TIMEZONE" @change="autoSave()"
:placeholder="settings.host_timezone || 'UTC'">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"
x-text="t('timezone_hint', {tz: settings.host_timezone || 'UTC'})"></p>
</div>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.TIMEZONE_ALL_APPS"
@change="settings.TIMEZONE_ALL_APPS=$event.target.checked; autoSave()"
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('timezone_all_apps')"></span>
</label>
</div>
</details>
@ -1432,16 +1494,39 @@ tailwind.config = {
<!-- Verbinden: netwerk en koppelingen -->
<template x-if="s.id === 'verbinden'">
<div class="space-y-3">
<label class="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" :checked="install.connect_apps !== false"
@change="install.connect_apps = $event.target.checked"
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
<span class="min-w-0">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
<span class="block text-[11px] text-dm dark:text-dm-dark"
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
<!-- Meerdere netwerken: niet elke app hoeft elke andere te
kunnen bereiken. -->
<div>
<div class="form-label" x-text="t('app_networks')">Gedeelde netwerken</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('app_networks_hint')"></p>
<div class="space-y-1.5">
<template x-for="n in appNets" :key="n.name">
<label class="flex items-center gap-2.5 cursor-pointer px-3 py-2 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<input type="checkbox" :checked="(install.app_networks||[]).includes(n.name)"
@change="toggleAppNet(n.name, $event.target.checked)"
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
<span class="min-w-0 flex-1">
<span class="text-sm font-mono text-tx dark:text-tx-dark" x-text="n.name"></span>
<span x-show="n.default" class="badge badge-dim ml-1.5" x-text="t('default_network')">standaard</span>
</span>
<span class="text-[11px] text-dm dark:text-dm-dark"
x-text="t('n_apps', {n: n.containers})"></span>
<button type="button" class="btn-sm !h-7 !px-2" x-show="!n.default"
@click.prevent="removeAppNet(n.name)" :title="t('delete')">
<span class="mdi mdi-delete-outline"></span>
</button>
</label>
</template>
</div>
<div class="flex gap-2 mt-2">
<input class="form-input font-mono !text-xs" x-model="newNetName"
:placeholder="t('new_network_placeholder')">
<button type="button" class="btn-sm shrink-0" @click="createAppNet()">
<span class="mdi mdi-plus"></span> <span x-text="t('new_network')">Netwerk maken</span>
</button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
@ -1453,7 +1538,27 @@ tailwind.config = {
<template x-if="install.network">
<div class="mt-2">
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
<div class="flex gap-2">
<input class="form-input font-mono" x-model="install.ip"
@change="checkIp()" placeholder="192.168.1.240">
<button type="button" class="btn-sm shrink-0" @click="checkIp()"
:title="t('check_availability')">
<span class="mdi mdi-magnify"></span>
</button>
</div>
<!-- Docker weet niets van fysieke apparaten op je LAN; die
botsing merk je pas weken later. -->
<p x-show="ipCheck.reason" class="text-[11px] text-wr mt-1">
<span class="mdi mdi-alert-circle-outline"></span>
<span x-text="ipCheck.reason"></span>
<button type="button" x-show="ipCheck.suggested" class="underline ml-1"
@click="install.ip = ipCheck.suggested; checkIp()"
x-text="t('use_free_address', {ip: ipCheck.suggested})"></button>
</p>
<p x-show="ipCheck.free" class="text-[11px] text-ok mt-1">
<span class="mdi mdi-check-circle-outline"></span>
<span x-text="t('address_available')"></span>
</p>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
</div>
</template>
@ -1652,7 +1757,7 @@ tailwind.config = {
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
</button>
<button class="btn btn-primary" x-show="wizardActief() && !opLaatsteStap()"
@click="nextStep()" :disabled="stepMissing().length">
@click="nextStep()" :disabled="stepMissing().length > 0">
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
</button>
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
@ -1739,6 +1844,23 @@ tailwind.config = {
</div>
</div>
</template>
<!-- Publieke URL via Pangolin -->
<template x-if="pangolin.configured">
<div class="pt-1">
<template x-if="creds.pangolin_url">
<a :href="creds.pangolin_url" target="_blank" rel="noopener"
class="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-ok/40 bg-ok/5">
<span class="mdi mdi-earth text-ok"></span>
<span class="font-mono text-xs text-tx dark:text-tx-dark" x-text="creds.pangolin_url"></span>
</a>
</template>
<button x-show="!creds.pangolin_url" class="btn w-full" @click="publiceerPangolin()"
:disabled="credsBusy">
<span class="mdi" :class="credsBusy ? 'mdi-loading mdi-spin' : 'mdi-earth-plus'"></span>
<span x-text="t('create_public_url')">Publieke URL aanmaken</span>
</button>
</div>
</template>
<p x-show="creds.self_signup" class="text-[11px] text-t2 dark:text-t2-dark flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('no_credentials_hint')"></span>
@ -1922,6 +2044,9 @@ function app() {
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
// Invulmenu: keuzes die over installaties heen blijven gelden.
showAdvanced: false, onePage: false, revealed: {},
appNets: [], newNetName: '', ipCheck: {}, portChecks: {},
pangolin: {configured: false, sites: [], domains: [], error: ''},
pangolinTesting: false, credsBusy: false,
creds: {stack: '', address: '', items: [], self_signup: false, reveal: {}},
notifyTesting: false,
stackUpd: {}, checkingUpd: false,
@ -2391,6 +2516,69 @@ function app() {
this.termOpen = true;
this.pollJob(d.job_id, () => this.loadBackups(this.bkStack));
},
// ── Pangolin ─────────────────────────────────────────────────────────────
async loadPangolin() {
const d = await this.rpc('/api/pangolin');
if (d && typeof d === 'object') this.pangolin = d;
},
async testPangolin() {
this.pangolinTesting = true;
try {
await this.saveSettings();
const d = await this.rpc('/api/pangolin/test', {});
this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
await this.loadPangolin();
} finally { this.pangolinTesting = false; }
},
async publiceerPangolin() {
this.credsBusy = true;
try {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(this.creds.stack)}/pangolin`, {});
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.creds.pangolin_url = d.url;
this.toast(this.t('public_url_created'), 'ok');
} finally { this.credsBusy = false; }
},
// ── Gedeelde netwerken ───────────────────────────────────────────────────
async loadAppNets(voor) {
const d = await this.rpc('/api/appnetworks?for=' + encodeURIComponent(voor || ''));
this.appNets = d.networks || [];
if (!this.newNetName) this.newNetName = d.suggested || '';
},
toggleAppNet(naam, aan) {
const lijst = new Set(this.install.app_networks || []);
aan ? lijst.add(naam) : lijst.delete(naam);
this.install.app_networks = [...lijst];
// De oude schakelaar blijft meelopen voor de klassieke stacks.
this.install.connect_apps = this.install.app_networks.length > 0;
},
async createAppNet() {
const d = await this.rpc('/api/appnetworks', { name: this.newNetName,
for: this.install.instance });
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.appNets = d.networks || [];
this.toggleAppNet(d.name, true);
this.newNetName = '';
this.toast(this.t('network_created', {name: d.name}), 'ok');
},
async removeAppNet(naam) {
if (!confirm(this.t('delete_network_confirm', {name: naam}))) return;
const d = await this.req('/api/appnetworks/' + encodeURIComponent(naam),
{ method: 'DELETE' });
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.toggleAppNet(naam, false);
await this.loadAppNets();
},
// Controleert subnet, bereik, Docker én de ARP-tabel van de host.
async checkIp() {
this.ipCheck = {};
if (!this.install.network || !this.install.ip) return;
const d = await this.rpc(`/api/networks/${encodeURIComponent(this.install.network)}`
+ `/ip-check?ip=${encodeURIComponent(this.install.ip)}`);
if (d && d.ok) this.ipCheck = d;
},
// ── Meldingen ────────────────────────────────────────────────────────────
async loadNotify() {
this.notify = await this.rpc('/api/notify');
@ -2713,6 +2901,14 @@ function app() {
this.install.preview_files = d.preview_files || {};
this.install.step = 0;
this.revealed = {};
this.ipCheck = {};
await this.loadAppNets(this.install.instance);
// Standaard alleen het gedeelde netwerk aanvinken, niet alle.
if (this.install.app_networks === undefined) {
const std = this.appNets.find(n => n.default);
this.install.app_networks = (this.install.connect_apps !== false && std)
? [std.name] : [];
}
} else {
this.install.envVars = d.env_vars || [];
this.install.images = d.images || [];
@ -2859,7 +3055,9 @@ function app() {
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
repo_id: d.repo_id || '',
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
};
await this.loadAppNets(stack);
this.fieldInfo = null;
this.revealed = {};
this.modal = 'install';
@ -2872,6 +3070,7 @@ function app() {
network: this.install.network,
ip: this.install.ip,
connect_apps: this.install.connect_apps !== false,
app_networks: this.install.app_networks || [],
});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
@ -2895,6 +3094,7 @@ function app() {
};
if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; }
body.connect_apps = this.install.connect_apps !== false;
body.app_networks = this.install.app_networks || [];
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
@ -2960,6 +3160,7 @@ function app() {
await this.loadUsers();
await this.loadNetworks();
await this.loadNotify();
await this.loadPangolin();
},
// Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
// een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.

View file

@ -317,5 +317,30 @@
"step_network": "Connect",
"step_settings": "Settings",
"step_secrets": "Access",
"step_review": "Review"
"step_review": "Review",
"address_available": "This address is free",
"app_networks": "Shared networks",
"app_networks_hint": "Apps on the same network can reach each other by container name. Not everything needs to be connected to everything: feel free to make a separate network for a group.",
"check_availability": "Check availability",
"default_network": "default",
"n_apps": "{n} containers",
"network_created": "Network '{name}' created",
"new_network": "Create network",
"new_network_hint": "Leave empty for an automatically chosen name. The network then also appears for other apps.",
"new_network_placeholder": "e.g. media",
"use_free_address": "use {ip}",
"port_free": "Port {port} is free",
"port_in_use": "Port {port} is already in use{by}",
"timezone": "Time zone",
"timezone_hint": "Used by new apps. Empty = this server's time zone ({tz}).",
"timezone_all_apps": "Also pass the time zone to apps without their own setting",
"create_public_url": "Create public URL",
"pangolin_domain": "Domain",
"pangolin_intro": "Pangolin gives your apps an address on the internet through a tunnel, without opening ports on your router. Enter your Pangolin server address and an API key; after that you can create a URL per app with one click.",
"pangolin_key": "API key",
"pangolin_org": "Organisation ID",
"pangolin_site": "Site",
"pangolin_url": "Pangolin address",
"public_url_created": "Public URL created",
"test_connection": "Test connection"
}

View file

@ -317,5 +317,30 @@
"step_network": "Verbinden",
"step_settings": "Instellingen",
"step_secrets": "Toegang",
"step_review": "Controleren"
"step_review": "Controleren",
"address_available": "Dit adres is vrij",
"app_networks": "Gedeelde netwerken",
"app_networks_hint": "Apps in hetzelfde netwerk kunnen elkaar op containernaam bereiken. Niet alles hoeft aan alles vast: maak gerust een apart netwerk voor een groepje.",
"check_availability": "Beschikbaarheid controleren",
"default_network": "standaard",
"n_apps": "{n} containers",
"network_created": "Netwerk '{name}' aangemaakt",
"new_network": "Netwerk maken",
"new_network_hint": "Laat het veld leeg voor een automatisch gekozen naam. Het netwerk komt daarna ook bij andere apps in de lijst.",
"new_network_placeholder": "bijv. media",
"use_free_address": "{ip} gebruiken",
"port_free": "Poort {port} is vrij",
"port_in_use": "Poort {port} is al in gebruik{by}",
"timezone": "Tijdzone",
"timezone_hint": "Wordt overgenomen door nieuwe apps. Leeg = de tijdzone van deze server ({tz}).",
"timezone_all_apps": "Ook apps zonder eigen tijdzone-instelling de tijdzone meegeven",
"create_public_url": "Publieke URL aanmaken",
"pangolin_domain": "Domein",
"pangolin_intro": "Pangolin geeft je apps een adres op internet via een tunnel, zonder poorten open te zetten in je router. Vul het adres van je Pangolin-server en een API-sleutel in; daarna kun je per app met één klik een URL aanmaken.",
"pangolin_key": "API-sleutel",
"pangolin_org": "Organisatie-id",
"pangolin_site": "Site",
"pangolin_url": "Adres van Pangolin",
"public_url_created": "Publieke URL aangemaakt",
"test_connection": "Verbinding testen"
}

101
tests/js/modal.dom.test.js Normal file
View file

@ -0,0 +1,101 @@
// Rendert de echte install-modal met Alpine in een DOM en klikt erop.
// Vangt bindingsfouten die je met losse logica niet ziet: :disabled="0"
// schakelt een knop juist UIT, want Alpine haalt een boolean-attribuut
// alleen weg bij null, undefined of false.
const fs = require('fs'), path = require('path');
// jsdom en Alpine zijn geen onderdeel van dit project; zonder die twee slaat
// deze test zichzelf over (afsluitcode 2). Installeren kan met:
// cd tests/js && npm install jsdom alpinejs
let JSDOM, alpinePad;
try {
({ JSDOM } = require('jsdom'));
alpinePad = require.resolve('alpinejs/dist/cdn.js');
} catch (e) {
console.log('overgeslagen: jsdom of alpinejs ontbreekt (npm install in tests/js)');
process.exit(2);
}
const WORTEL = path.resolve(__dirname, '..', '..');
const html = fs.readFileSync(WORTEL + '/server-up/templates/index.html', 'utf8');
const dom = new JSDOM('<!doctype html><html><body></body></html>',
{ runScripts: 'dangerously', pretendToBeVisual: true, url: 'http://localhost/' });
const w = dom.window;
w.fetch = async () => ({ ok: true, json: async () => ({ ok: true }) });
const start = html.indexOf('function app() {');
w.eval(html.slice(start, html.indexOf('</script>', start)));
// De component vóór Alpine klaarzetten: erna is het een proxy die dit weigert.
const echt = w.app;
w.app = function () {
const c = echt();
c.t = k => k;
c.bpFieldVisible = () => true;
c.toast = () => {};
c.rpc = async () => ({ ok: true, preview_files: { 'compose.yaml': 'services: {}' }, env_preview: '' });
c.connectTargets = () => [];
c.pickIp = () => {};
c.netList = [];
c.sharedNetwork = 'serverup';
c.modal = 'install';
c.install = {
format: 'boilerplate', loading: false, stack: 'test', repo_id: 'r', instance: 'test',
values: { service_name: 'test', port: 8080 },
fields: [
{name:'service_name',title:'Naam',step:'basis',type:'str',group:'Algemeen',required:true,advanced:false},
{name:'port',title:'Poort',step:'basis',type:'int',group:'Algemeen',required:true,advanced:false},
{name:'db_password',title:'Wachtwoord',step:'geheimen',type:'str',group:'Algemeen',required:true,secret:true,advanced:false},
],
step: 0, preview_files: {}, composeText: '', envText: '', editing: false, tab: 'compose',
mode: 'install', connect_apps: true, network: '', ip: '', envVars: [], images: [],
envOverrides: {}, imageTags: {},
};
return c;
};
const m = html.match(/<div x-show="modal==='install'"[\s\S]*?\n <\/div>\n/);
w.document.body.innerHTML = `<div x-data="app()">${m[0]}</div>`;
w.eval(fs.readFileSync(alpinePad, 'utf8'));
const wacht = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await wacht(100);
const comp = w.Alpine.$data(w.document.querySelector('[x-data]'));
const knoppen = [...w.document.querySelectorAll('.modal-foot button')];
console.log('knoppen in de voet:');
knoppen.forEach(b => console.log(
` "${b.textContent.trim().slice(0,14)}" zichtbaar=${b.style.display !== 'none'} disabled=${b.disabled}`));
let fouten = 0;
const check = (naam, waar) => { console.log((waar ? ' ✓ ' : ' ✖ ') + naam); if (!waar) fouten++; };
check('velden worden gerenderd', w.document.querySelectorAll('.modal-body input').length > 0);
check('niets ontbreekt op stap 1', comp.stepMissing().length === 0);
const volgende = knoppen.find(b => b.textContent.includes('next'));
check('Volgende staat in de DOM', !!volgende);
if (!volgende) process.exit(1);
// Alpine haalt een boolean-attribuut alleen weg bij null, undefined of false.
// Een lege lijst gaf dus .length === 0, en dat zette disabled juist AAN.
check('Volgende is niet uitgeschakeld met alles ingevuld', volgende.disabled === false);
volgende.click();
await wacht(120);
check('klikken gaat naar de volgende stap', comp.install.step === 1);
// Leeg verplicht veld hoort wél te blokkeren.
comp.install.step = 2;
await wacht(60);
const geheimen = comp.wizardSteps()[2];
check('stap 3 is de geheimenstap', geheimen && geheimen.id === 'geheimen');
check('leeg wachtwoord blokkeert', comp.stepMissing().length === 1);
await wacht(60);
check('knop is dan uitgeschakeld', volgende.disabled === true);
comp.install.values.db_password = 'ietsGeheims';
await wacht(80);
check('knop weer bruikbaar na invullen', volgende.disabled === false);
console.log(fouten === 0 ? '\nAlle DOM-controles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();

9
tests/js/package.json Normal file
View file

@ -0,0 +1,9 @@
{
"name": "server-up-ui-tests",
"private": true,
"description": "Alleen voor de DOM-test van de installatiemodal. Niet nodig om Server Up te draaien; de test slaat zichzelf over als deze pakketten ontbreken.",
"devDependencies": {
"alpinejs": "^3.14.0",
"jsdom": "^25.0.0"
}
}

View file

@ -0,0 +1,318 @@
"""Tijdzone, vrije poorten, vrije IP-adressen en gedeelde netwerken.
Allemaal dingen waar Server Up de gebruiker eerder mee liet gokken: de tijdzone
stond in elk sjabloon hard op Europe/Amsterdam, poortcontrole keek alleen naar
Docker, en er was één gedeeld netwerk waar élke app aan hing.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
LAN = {"name": "lan", "driver": "macvlan", "parent": "eth0",
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
"ip_range": "192.168.1.240/28"}
# ── Tijdzone ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("tz,geldig", [
("Europe/Amsterdam", True), ("UTC", True), ("America/Argentina/Salta", True),
("Etc/GMT+2", True),
("", False), ("geen; tijdzone", False), ("../../etc/passwd", False),
("a" * 80, False), ("Europe/Amsterdam; rm -rf /", False),
])
def test_tijdzonevorm(env, tz, geldig):
assert env["core"].geldige_tijdzone(tz) is geldig
def test_host_tijdzone_uit_de_omgeving(env, monkeypatch):
monkeypatch.setenv("TZ", "Asia/Tokyo")
assert env["core"].host_timezone() == "Asia/Tokyo"
def test_host_tijdzone_negeert_onzin(env, monkeypatch):
"""Een kapotte TZ mag niet doorsijpelen naar elk compose-bestand."""
monkeypatch.setenv("TZ", "niet; goed")
assert env["core"].host_timezone() != "niet; goed"
def test_ingestelde_tijdzone_wint(env, monkeypatch):
monkeypatch.setenv("TZ", "Asia/Tokyo")
env["core"].patch({"TIMEZONE": "Europe/Berlin"})
assert env["core"].tijdzone() == "Europe/Berlin"
def test_zonder_instelling_die_van_de_server(env, monkeypatch):
monkeypatch.setenv("TZ", "Asia/Tokyo")
env["core"].patch({"TIMEZONE": ""})
assert env["core"].tijdzone() == "Asia/Tokyo"
def test_ongeldige_tijdzone_wordt_geweigerd(client):
csrf = login(client)
r = client.put("/api/settings", json={"TIMEZONE": "geen; tijdzone"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "tijdzone" in r.get_json()["msg"].lower()
def test_instellingen_tonen_de_tijdzone_van_de_server(client, env):
login(client)
d = client.get("/api/settings").get_json()
assert d["host_timezone"]
def test_tijdzone_wordt_toegevoegd_waar_die_ontbreekt(env):
"""Negentien sjablonen hebben geen tijdzoneveld; die containers draaiden in
UTC en al hun tijdstempels stonden er een paar uur naast."""
from core import compose_transform as ct
compose = ("services:\n a:\n image: nginx\n"
" b:\n image: redis\n environment:\n - FOO=bar\n")
uit = ct.zorg_voor_tijdzone(compose, "Europe/Amsterdam")
import yaml
doc = yaml.safe_load(uit)
assert "TZ=Europe/Amsterdam" in doc["services"]["a"]["environment"]
assert "TZ=Europe/Amsterdam" in doc["services"]["b"]["environment"]
assert "FOO=bar" in doc["services"]["b"]["environment"]
def test_bestaande_tijdzone_blijft_staan(env):
"""Heeft de app zelf een tijdzoneveld, dan is dat de keuze van de gebruiker."""
from core import compose_transform as ct
compose = ("services:\n a:\n image: nginx\n"
" environment:\n - TZ=Asia/Tokyo\n")
assert ct.zorg_voor_tijdzone(compose, "Europe/Amsterdam") == compose
def test_tijdzone_als_mapping(env):
from core import compose_transform as ct
import yaml
uit = ct.zorg_voor_tijdzone(
"services:\n a:\n image: nginx\n environment:\n FOO: bar\n",
"Europe/Berlin")
assert yaml.safe_load(uit)["services"]["a"]["environment"]["TZ"] == "Europe/Berlin"
# ── Poorten ──────────────────────────────────────────────────────────────────
def test_poortcontrole_meldt_vrij(client, env):
login(client)
d = client.get("/api/ports/check?port=54321").get_json()
assert d["ok"] is True
assert d["free"] is True
assert d["suggested"] == 54321
def test_poortcontrole_stelt_een_alternatief_voor(client, env, monkeypatch):
import app as app_module
monkeypatch.setattr(app_module, "_bezette_poorten", lambda: {8080, 8081})
login(client)
d = client.get("/api/ports/check?port=8080").get_json()
assert d["free"] is False
assert d["suggested"] == 8082
@pytest.mark.parametrize("waarde", ["0", "70000", "abc", "-1"])
def test_ongeldige_poort(client, env, waarde):
login(client)
assert client.get(f"/api/ports/check?port={waarde}").status_code == 400
def test_luisterpoorten_uit_proc(env):
"""/proc/net/tcp: kolom 4 is de status, 0A betekent LISTEN."""
from core import networks
tekst = (" sl local_address rem_address st tx_queue\n"
" 0: 0100007F:1F90 00000000:0000 0A 00000000\n" # 8080, LISTEN
" 1: 0100007F:0050 0100007F:C001 01 00000000\n" # 80, ESTABLISHED
" 2: 00000000:0035 00000000:0000 0A 00000000\n") # 53, LISTEN
assert networks._poorten_uit_proc(tekst) == {8080, 53}
def test_poorten_van_de_host_tellen_mee(env, monkeypatch):
"""Een nginx die rechtstreeks op de host draait ziet `docker ps` niet, maar
je botst er wel mee: 'port is already allocated'."""
import app as app_module
from core import docker, networks
monkeypatch.setattr(docker, "used_ports", lambda: {8080})
monkeypatch.setattr(networks, "host_luisterpoorten", lambda *a, **k: {443})
assert app_module._bezette_poorten() == {8080, 443}
# ── IP-adressen ──────────────────────────────────────────────────────────────
@pytest.fixture
def lan(client, env):
env["core"].patch({"NETWORKS": [dict(LAN)]})
return client
def test_ip_buiten_het_subnet(lan, env):
login(lan)
d = lan.get("/api/networks/lan/ip-check?ip=10.9.9.9").get_json()
assert d["free"] is False
assert d["in_subnet"] is False
assert "buiten het subnet" in d["reason"]
def test_gateway_is_geen_vrij_adres(lan, env):
login(lan)
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.1").get_json()
assert d["free"] is False
assert "gateway" in d["reason"]
def test_buiten_het_ingestelde_bereik(lan, env):
"""Binnen het subnet maar buiten je range: dan zit je in het gebied waar je
router DHCP uitdeelt, en botst het vroeg of laat."""
login(lan)
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.50").get_json()
assert d["free"] is False
assert "bereik" in d["reason"]
def test_vrij_adres(lan, env):
login(lan)
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.242").get_json()
assert d["free"] is True
assert d["reason"] == ""
def test_advies_zonder_adres(lan, env):
login(lan)
d = lan.get("/api/networks/lan/ip-check").get_json()
assert d["suggested"].startswith("192.168.1.24")
def test_apparaat_op_het_lan_blokkeert(lan, env, monkeypatch):
"""Docker kent alleen zijn eigen containers; een fysiek apparaat met een
DHCP-adres is precies de botsing die pas weken later opvalt."""
from core import networks
monkeypatch.setattr(networks, "buren_op_het_lan", lambda: {"192.168.1.242"})
login(lan)
d = lan.get("/api/networks/lan/ip-check?ip=192.168.1.242").get_json()
assert d["free"] is False
assert "apparaat" in d["reason"]
def test_arp_tabel_negeert_onvolledige_regels(env):
from core import networks
tekst = ("IP address HW type Flags HW address Mask Device\n"
"192.168.1.5 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0\n"
"192.168.1.9 0x1 0x0 00:00:00:00:00:00 * eth0\n")
assert networks._arp_tabel(tekst) == {"192.168.1.5"}
def test_onbekend_netwerk(client, env):
login(client)
assert client.get("/api/networks/bestaatniet/ip-check?ip=1.2.3.4").status_code == 404
# ── Gedeelde netwerken ───────────────────────────────────────────────────────
def test_standaardnetwerk_staat_in_de_lijst(client, env):
login(client)
d = client.get("/api/appnetworks").get_json()
namen = [n["name"] for n in d["networks"]]
assert "serverup" in namen
assert next(n for n in d["networks"] if n["name"] == "serverup")["default"]
def test_voorstel_voor_een_nieuwe_naam(client, env):
login(client)
d = client.get("/api/appnetworks?for=jellyfin").get_json()
assert d["suggested"] == "su-jellyfin"
def test_voorstel_wijkt_uit_bij_een_bestaande_naam(env):
from core import networks
env["core"].patch({"APP_NETWORKS": ["su-media"]})
assert networks.voorstel_netwerknaam("media") == "su-media-2"
def test_netwerk_toevoegen_onthoudt_het(env, monkeypatch):
from core import networks
monkeypatch.setattr(networks, "ensure_shared", lambda n: (True, ""))
ok, _ = networks.app_netwerk_toevoegen("media")
assert ok is True
assert "media" in env["core"].load()["APP_NETWORKS"]
@pytest.mark.parametrize("naam", ["../weg", "met spatie", "", "-begint-fout"])
def test_ongeldige_netwerknaam(env, naam):
from core import networks
ok, msg = networks.app_netwerk_toevoegen(naam)
assert ok is False
assert "naam" in msg.lower()
def test_standaardnetwerk_kan_niet_weg(env):
from core import networks
ok, msg = networks.app_netwerk_verwijderen("serverup")
assert ok is False
assert "standaard" in msg
def test_netwerk_met_containers_kan_niet_weg(env, monkeypatch):
from core import networks
env["core"].patch({"APP_NETWORKS": ["media"]})
monkeypatch.setattr(networks, "_containers_in", lambda n: {"x": {}})
ok, msg = networks.app_netwerk_verwijderen("media")
assert ok is False
assert "containers" in msg
def test_netwerkbeheer_is_voor_beheerders(client, env):
csrf = login(client)
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
op = client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
r = client.post("/api/appnetworks", json={"name": "media"},
headers={"X-CSRF-Token": op})
assert r.status_code == 403
# ── Meerdere netwerken in compose ────────────────────────────────────────────
def test_stack_kan_aan_meerdere_netwerken(env):
from core import compose_transform as ct
import yaml
uit = ct.add_shared_network("services:\n a:\n image: nginx\n",
["serverup", "media"])
doc = yaml.safe_load(uit)
assert doc["services"]["a"]["networks"] == ["serverup", "media"]
assert set(doc["networks"]) == {"serverup", "media"}
def test_een_naam_blijft_werken(env):
"""De oude aanroep met één string mag niet stukgaan."""
from core import compose_transform as ct
import yaml
doc = yaml.safe_load(ct.add_shared_network(
"services:\n a:\n image: nginx\n", "serverup"))
assert doc["services"]["a"]["networks"] == ["serverup"]
def test_lege_lijst_verandert_niets(env):
from core import compose_transform as ct
compose = "services:\n a:\n image: nginx\n"
assert ct.add_shared_network(compose, []) == compose
def test_network_mode_wordt_nog_steeds_overgeslagen(env):
"""Compose weigert network_mode én networks in dezelfde service."""
from core import compose_transform as ct
import yaml
doc = yaml.safe_load(ct.add_shared_network(
"services:\n a:\n image: nginx\n network_mode: host\n",
["serverup", "media"]))
assert "networks" not in doc["services"]["a"]

274
tests/test_pangolin.py Normal file
View file

@ -0,0 +1,274 @@
"""Pangolin: publieke URL's voor je apps.
Getest tegen een nagebouwde API, niet tegen een echte Pangolin die heb ik
hier niet. Wat hier vastligt is dus dat wij de juiste verzoeken sturen en met
alle antwoorden overweg kunnen, niet dat de echte server ze accepteert.
De API veranderde in 1.9: een resource hing eerst onder een site en staat nu
los. Beide routes worden geprobeerd; zie fosrl/pangolin#1344.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
@pytest.fixture
def pg(env):
from core import pangolin
env["core"].patch({"PANGOLIN_URL": "https://pangolin.test",
"PANGOLIN_API_KEY": "sleutel123",
"PANGOLIN_ORG_ID": "mijnorg"})
return pangolin
@pytest.fixture
def nep(pg, monkeypatch):
"""Vangt de verzoeken op en beantwoordt ze volgens een script."""
verzonden = []
antwoorden = {}
def _verzoek(methode, pad, body=None):
verzonden.append({"methode": methode, "pad": pad, "body": body})
for sleutel, waarde in antwoorden.items():
if sleutel[0] == methode and pad.startswith(sleutel[1]):
return waarde
return 404, {"message": "niet gevonden"}
monkeypatch.setattr(pg, "_verzoek", _verzoek)
return {"verzonden": verzonden, "antwoorden": antwoorden}
# ── Instellingen ─────────────────────────────────────────────────────────────
def test_zonder_instellingen_uitgeschakeld(env):
from core import pangolin
assert pangolin.ingesteld() is False
def test_met_instellingen_ingeschakeld(pg):
assert pg.ingesteld() is True
def test_sleutel_lekt_niet_via_de_api(client, env):
csrf = login(client)
client.put("/api/settings",
json={"PANGOLIN_URL": "https://pangolin.test",
"PANGOLIN_API_KEY": "zeergeheim", "PANGOLIN_ORG_ID": "o"},
headers={"X-CSRF-Token": csrf})
body = client.get("/api/pangolin").get_data(as_text=True)
assert "zeergeheim" not in body
assert '"has_key":true' in body.replace(" ", "")
assert "zeergeheim" not in client.get("/api/settings").get_data(as_text=True)
assert env["core"].load()["PANGOLIN_API_KEY"] == "zeergeheim"
# ── Verbinding testen ────────────────────────────────────────────────────────
def test_test_zonder_organisatie(pg, env):
env["core"].patch({"PANGOLIN_ORG_ID": ""})
ok, msg = pg.test()
assert ok is False
assert "organisatie" in msg
def test_test_meldt_een_geweigerde_sleutel(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (401, {"message": "unauthorized"})
ok, msg = pg.test()
assert ok is False
assert "401" in msg
def test_test_slaagt(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (200, {"sites": [{"siteId": 1, "name": "thuis"}]})
ok, msg = pg.test()
assert ok is True
assert "1 site" in msg
def test_netwerkfout_wordt_gemeld(pg, monkeypatch):
def _stuk(*a, **kw):
raise pg.PangolinError("TimeoutError: te traag")
monkeypatch.setattr(pg, "_verzoek", _stuk)
ok, msg = pg.test()
assert ok is False
assert "Timeout" in msg
# ── Lijsten uitpakken ────────────────────────────────────────────────────────
@pytest.mark.parametrize("antwoord", [
{"sites": [{"siteId": 7, "name": "thuis"}]},
{"data": {"sites": [{"siteId": 7, "name": "thuis"}]}},
{"data": [{"siteId": 7, "name": "thuis"}]},
[{"siteId": 7, "name": "thuis"}],
])
def test_sites_uit_verschillende_verpakkingen(pg, nep, antwoord):
"""Pangolin verpakt lijsten wisselend in `data`; allebei aankunnen."""
nep["antwoorden"][("GET", "/org/")] = (200, antwoord)
assert pg.sites() == [{"id": 7, "name": "thuis"}]
def test_domeinen(pg, nep):
nep["antwoorden"][("GET", "/org/")] = (
200, {"domains": [{"domainId": "d1", "baseDomain": "voorbeeld.nl"}]})
assert pg.domeinen() == [{"id": "d1", "domain": "voorbeeld.nl"}]
# ── Publiceren ───────────────────────────────────────────────────────────────
def _standaard(nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (
200, {"resourceId": 42, "fullDomain": "app.voorbeeld.nl"})
nep["antwoorden"][("PUT", "/resource/42/target")] = (200, {"targetId": 1})
def test_publiceren_maakt_resource_en_target(pg, nep):
_standaard(nep)
res = pg.publiceer("mijnapp", "mijnapp", "192.168.1.50", 8080,
site_id=3, domein_id="d1")
assert res["url"] == "https://app.voorbeeld.nl"
assert res["resource_id"] == 42
resource = nep["verzonden"][0]
assert resource["methode"] == "PUT"
assert resource["body"]["name"] == "mijnapp"
assert resource["body"]["http"] is True
assert resource["body"]["domainId"] == "d1"
target = nep["verzonden"][1]
assert target["pad"] == "/resource/42/target"
assert target["body"] == {"siteId": 3, "ip": "192.168.1.50", "port": 8080,
"method": "http", "enabled": True}
def test_valt_terug_op_de_oude_route(pg, nep):
"""Tot 1.9 hing een resource onder een site en moest siteId in de body."""
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (
400, {"message": "Validation error: Unrecognized key(s) in object: 'siteId'"})
nep["antwoorden"][("PUT", "/org/mijnorg/site/3/resource")] = (
200, {"resourceId": 42, "fullDomain": "app.voorbeeld.nl"})
nep["antwoorden"][("PUT", "/resource/42/target")] = (200, {})
res = pg.publiceer("mijnapp", "mijnapp", "192.168.1.50", 8080,
site_id=3, domein_id="d1")
assert res["resource_id"] == 42
assert nep["verzonden"][1]["pad"] == "/org/mijnorg/site/3/resource"
assert nep["verzonden"][1]["body"]["siteId"] == 3
def test_beide_routes_mislukken_geeft_beide_meldingen(pg, nep):
"""Anders sta je met een foutmelding die maar de helft van het verhaal is."""
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (400, {"message": "nieuw stuk"})
nep["antwoorden"][("PUT", "/org/mijnorg/site/3/resource")] = (
500, {"message": "oud ook stuk"})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "nieuw stuk" in str(exc.value)
assert "oud ook stuk" in str(exc.value)
def test_zonder_resource_id_stopt_hij(pg, nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (200, {"ok": True})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "resource-id" in str(exc.value)
def test_domein_wordt_opgehaald_als_het_niet_is_ingesteld(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/domains")] = (
200, {"domains": [{"domainId": "auto", "baseDomain": "x.nl"}]})
_standaard(nep)
pg.publiceer("mijnapp", "mijnapp", "1.2.3.4", 80, site_id=3)
resource = next(v for v in nep["verzonden"] if v["pad"].endswith("/resource"))
assert resource["body"]["domainId"] == "auto"
def test_zonder_domein_nette_fout(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/domains")] = (200, {"domains": []})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3)
assert "domein" in str(exc.value).lower()
def test_zonder_site_wijst_hij_naar_newt(pg, nep):
nep["antwoorden"][("GET", "/org/mijnorg/sites")] = (200, {"sites": []})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, domein_id="d1")
assert "Newt" in str(exc.value)
def test_mislukt_target_wordt_gemeld(pg, nep):
nep["antwoorden"][("PUT", "/org/mijnorg/resource")] = (200, {"resourceId": 42})
nep["antwoorden"][("PUT", "/resource/42/target")] = (400, {"message": "poort bezet"})
with pytest.raises(pg.PangolinError) as exc:
pg.publiceer("a", "a", "1.2.3.4", 80, site_id=3, domein_id="d1")
assert "poort bezet" in str(exc.value)
# ── Via de API ───────────────────────────────────────────────────────────────
def test_publiceren_zonder_instellingen(client, env):
csrf = login(client)
(env["lib"] / "web").mkdir(parents=True, exist_ok=True)
(env["lib"] / "web" / "docker-compose.yml").write_text(
"services:\n web:\n image: nginx\n", encoding="utf-8")
r = client.post("/api/stacks/web/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "niet ingesteld" in r.get_json()["msg"]
def test_publiceren_van_onbekende_stack(client, env):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_poort_wordt_uit_compose_gehaald(client, env, monkeypatch):
from core import pangolin
csrf = login(client)
env["core"].patch({"PANGOLIN_URL": "https://p.test", "PANGOLIN_API_KEY": "k",
"PANGOLIN_ORG_ID": "o"})
d = env["lib"] / "web"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
'services:\n web:\n image: nginx\n ports:\n - "8123:80"\n',
encoding="utf-8")
(d / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.60"}),
encoding="utf-8")
gezien = {}
def _publiceer(naam, subdomein, ip, poort, site_id=None, domein_id=""):
gezien.update({"ip": ip, "poort": poort, "sub": subdomein})
return {"url": "https://web.voorbeeld.nl", "resource_id": 1}
monkeypatch.setattr(pangolin, "publiceer", _publiceer)
r = client.post("/api/stacks/web/pangolin", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert gezien["ip"] == "192.168.1.60" # eigen IP gaat voor
assert gezien["poort"] == 8123
# En de URL wordt bewaard bij de stack.
meta = json.loads((d / ".serverup.json").read_text())
assert meta["pangolin_url"] == "https://web.voorbeeld.nl"
def test_alleen_beheerders(client, env):
csrf = login(client)
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
op = client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
r = client.post("/api/pangolin/test", headers={"X-CSRF-Token": op})
assert r.status_code == 403

View file

@ -21,6 +21,21 @@ def test_invulmenu_logica():
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_installatiemodal_in_een_echte_dom():
"""Rendert de modal met Alpine en klikt op Volgende.
Losse logica zegt niets over de binding: `:disabled="lijst.length"` gaf bij
een lege lijst 0, en Alpine haalt een boolean-attribuut alleen weg bij
null, undefined of false dus de knop stond juist uit.
"""
r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "modal.dom.test.js")],
capture_output=True, text=True, timeout=90, cwd=str(WORTEL))
if r.returncode == 2:
pytest.skip(r.stdout.strip() or "jsdom ontbreekt")
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_javascript_van_de_pagina_parseert():
"""Een typefout in de component maakt de hele interface blanco; dat wil je