From ea9b1eb668b457eae7cd5158ac10fee5ddf4bc75 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 28 Jul 2026 22:25:38 +0200 Subject: [PATCH] v0.7.80-beta - Volgende-knop, tijdzones, netwerken per groepje, Pangolin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-). - add_shared_network() accepteert een lijst; één naam blijft werken. - GET /api/networks//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 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- .gitignore | 1 + CHANGELOG.md | 50 ++++ VERSION | 2 +- docs/README.md | 1 + docs/netwerken.md | 60 +++++ docs/pangolin.md | 78 +++++++ server-up/app.py | 344 +++++++++++++++++++++++++--- server-up/core/__init__.py | 60 ++++- server-up/core/compose_transform.py | 72 +++++- server-up/core/networks.py | 146 ++++++++++++ server-up/core/pangolin.py | 206 +++++++++++++++++ server-up/templates/index.html | 225 +++++++++++++++++- server-up/translations/en.json | 27 ++- server-up/translations/nl.json | 27 ++- tests/js/modal.dom.test.js | 101 ++++++++ tests/js/package.json | 9 + tests/test_netwerk_advies.py | 318 +++++++++++++++++++++++++ tests/test_pangolin.py | 274 ++++++++++++++++++++++ tests/test_ui_logica.py | 15 ++ 19 files changed, 1956 insertions(+), 60 deletions(-) create mode 100644 docs/pangolin.md create mode 100644 server-up/core/pangolin.py create mode 100644 tests/js/modal.dom.test.js create mode 100644 tests/js/package.json create mode 100644 tests/test_netwerk_advies.py create mode 100644 tests/test_pangolin.py diff --git a/.gitignore b/.gitignore index 19518ff..1501d07 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env .pytest_cache/ venv/ +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ed782..9beecff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-`). 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 diff --git a/VERSION b/VERSION index 296fb7a..f0facb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.70-beta +0.7.80-beta diff --git a/docs/README.md b/docs/README.md index 2bc3033..a95aebe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. | diff --git a/docs/netwerken.md b/docs/netwerken.md index 92ebb79..001a590 100644 --- a/docs/netwerken.md +++ b/docs/netwerken.md @@ -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-`). 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. diff --git a/docs/pangolin.md b/docs/pangolin.md new file mode 100644 index 0000000..7d4b924 --- /dev/null +++ b/docs/pangolin.md @@ -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 `.`; +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//site//resource`) en staat sindsdien los +(`PUT /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. diff --git a/server-up/app.py b/server-up/app.py index a7e9308..4783b31 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -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 ok_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.") - else: + 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//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/", 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//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 diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index 3f33170..464ee80 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -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. diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index cf0a1bd..9780165 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -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. - huidig = svc.get("networks") - if huidig is None: - svc["networks"] = [network] - elif isinstance(huidig, list): - if network not in huidig: - huidig.append(network) - elif isinstance(huidig, dict): - huidig.setdefault(network, None) + for naam in namen: + huidig = svc.get("networks") + if huidig is None: + svc["networks"] = [naam] + elif isinstance(huidig, list): + if naam not in huidig: + huidig.append(naam) + elif isinstance(huidig, dict): + 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`. diff --git a/server-up/core/networks.py b/server-up/core/networks.py index f432c3b..ad9d60c 100644 --- a/server-up/core/networks.py +++ b/server-up/core/networks.py @@ -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." diff --git a/server-up/core/pangolin.py b/server-up/core/pangolin.py new file mode 100644 index 0000000..dd64ed0 --- /dev/null +++ b/server-up/core/pangolin.py @@ -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 `. + +**Let op de versieverschillen.** Tot 1.9 hing een resource onder een site +(`PUT /org//site//resource`); daarna staat de resource los en komt de +site pas bij het target erbij (`PUT /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} diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 05812f9..ba38d8a 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -883,6 +883,55 @@ tailwind.config = { + +
+
+ + Pangolin +
+
+

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ +
+
+
@@ -1032,6 +1081,19 @@ tailwind.config = {
+
+ + +

+
+
@@ -1432,16 +1494,39 @@ tailwind.config = { + +

@@ -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. diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 8bb1f2e..4155462 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -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" } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 390599d..4e1e517 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -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" } diff --git a/tests/js/modal.dom.test.js b/tests/js/modal.dom.test.js new file mode 100644 index 0000000..a7846f1 --- /dev/null +++ b/tests/js/modal.dom.test.js @@ -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('', + { 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('', 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(/

\n/); +w.document.body.innerHTML = `
${m[0]}
`; +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); +})(); diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 0000000..f4c8348 --- /dev/null +++ b/tests/js/package.json @@ -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" + } +} diff --git a/tests/test_netwerk_advies.py b/tests/test_netwerk_advies.py new file mode 100644 index 0000000..9f2ae0a --- /dev/null +++ b/tests/test_netwerk_advies.py @@ -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"] diff --git a/tests/test_pangolin.py b/tests/test_pangolin.py new file mode 100644 index 0000000..b198061 --- /dev/null +++ b/tests/test_pangolin.py @@ -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 diff --git a/tests/test_ui_logica.py b/tests/test_ui_logica.py index 913c936..542bf5c 100644 --- a/tests/test_ui_logica.py +++ b/tests/test_ui_logica.py @@ -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