v0.7.80-beta - Volgende-knop, tijdzones, netwerken per groepje, Pangolin
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m10s
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:
parent
bc55252267
commit
ea9b1eb668
19 changed files with 1956 additions and 60 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ __pycache__/
|
||||||
.env
|
.env
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
venv/
|
venv/
|
||||||
|
node_modules/
|
||||||
|
|
|
||||||
50
CHANGELOG.md
50
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-<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
|
# v0.7.70-beta — Installeren via een invulmenu, geheimen naar .env
|
||||||
|
|
||||||
**Het installatieformulier is een invulmenu geworden.** Eén lange lijst werkte
|
**Het installatieformulier is een invulmenu geworden.** Eén lange lijst werkte
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.7.70-beta
|
0.7.80-beta
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [synchroniseren.md](synchroniseren.md) | Code van je eigen apparaat automatisch naar de server krijgen via Forgejo. |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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
78
docs/pangolin.md
Normal 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.
|
||||||
340
server-up/app.py
340
server-up/app.py
|
|
@ -1,6 +1,6 @@
|
||||||
"""Server Up — Docker Manager."""
|
"""Server Up — Docker Manager."""
|
||||||
from __future__ import annotations
|
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 datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ import core as cfg
|
||||||
from core import audit, jobs, i18n
|
from core import audit, jobs, i18n
|
||||||
from core import auth, docker, git, boilerplates, updater
|
from core import auth, docker, git, boilerplates, updater
|
||||||
from core import backups, categories, compose_transform, diskspace, networks
|
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 scheduler, selfupdate
|
||||||
from core import stackupdates
|
from core import stackupdates
|
||||||
from core.modules import Module, CORE, discover
|
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/auth/users", "/api/auth/mode", "/api/settings", "/api/networks",
|
||||||
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
||||||
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
"/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.
|
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
||||||
|
|
@ -803,6 +804,7 @@ def api_stack_credentials(name):
|
||||||
audit.log("stacks", "credentials_view", "ok", ref=name,
|
audit.log("stacks", "credentials_view", "ok", ref=name,
|
||||||
ip=request.remote_addr)
|
ip=request.remote_addr)
|
||||||
return jsonify(ok=True, address=adres, items=items,
|
return jsonify(ok=True, address=adres, items=items,
|
||||||
|
pangolin_url=meta.get("pangolin_url", ""),
|
||||||
# Apps als Vaultwarden en Immich maken het eerste account aan
|
# Apps als Vaultwarden en Immich maken het eerste account aan
|
||||||
# bij het eerste bezoek; dan valt er hier niets te tonen.
|
# bij het eerste bezoek; dan valt er hier niets te tonen.
|
||||||
self_signup=not items)
|
self_signup=not items)
|
||||||
|
|
@ -920,6 +922,7 @@ def api_stack_config(name):
|
||||||
values=waarden,
|
values=waarden,
|
||||||
network=meta.get("network", ""), ip=meta.get("ip", ""),
|
network=meta.get("network", ""), ip=meta.get("ip", ""),
|
||||||
connected=bool(meta.get("connected")),
|
connected=bool(meta.get("connected")),
|
||||||
|
app_networks=meta.get("app_networks") or [],
|
||||||
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
||||||
name=meta.get("name", name))
|
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_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 ""
|
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()
|
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
|
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
|
||||||
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
|
# 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),
|
cf.read_text(encoding="utf-8"), net_name, net_ip),
|
||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}")
|
jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}")
|
||||||
if verbinden and gedeeld_net:
|
if app_netten:
|
||||||
ok_net, _ = networks.ensure_shared(gedeeld_net)
|
klaar = []
|
||||||
|
for naam in app_netten:
|
||||||
|
ok_net, fout_net = networks.ensure_shared(naam)
|
||||||
if ok_net:
|
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.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 '{gedeeld_net}'")
|
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
|
||||||
|
|
||||||
ok_cfg, err = docker.validate_compose(d)
|
ok_cfg, err = docker.validate_compose(d)
|
||||||
if not ok_cfg:
|
if not ok_cfg:
|
||||||
|
|
@ -1017,7 +1029,8 @@ def api_stack_reconfigure(name):
|
||||||
"values": zichtbaar, "secret_fields": geheime_namen,
|
"values": zichtbaar, "secret_fields": geheime_namen,
|
||||||
"secret_keys": secret_keys,
|
"secret_keys": secret_keys,
|
||||||
"network": net_name, "ip": net_ip,
|
"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)
|
docker.compose_down(d, log_fn=lf, name=name)
|
||||||
rc = docker.compose_up(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)
|
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]:
|
def _annotate_fields(fields: list[dict]) -> list[dict]:
|
||||||
"""Verrijk de installatievelden met een vrije poort en een secret-vlag.
|
"""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';
|
installatie niet strandt op 'port is already allocated';
|
||||||
- geheime velden worden gemarkeerd zodat de UI een genereerknop toont.
|
- geheime velden worden gemarkeerd zodat de UI een genereerknop toont.
|
||||||
"""
|
"""
|
||||||
try:
|
bezet = _bezette_poorten()
|
||||||
bezet = docker.used_ports()
|
tz = cfg.tijdzone()
|
||||||
except Exception:
|
|
||||||
bezet = set()
|
|
||||||
uitgedeeld: set[int] = set()
|
uitgedeeld: set[int] = set()
|
||||||
for f in fields:
|
for f in fields:
|
||||||
if _is_secret_field(f):
|
if _is_secret_field(f):
|
||||||
f["secret"] = True
|
f["secret"] = True
|
||||||
naam = (f.get("name") or "").lower()
|
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:
|
if f.get("type") == "int" and "port" in naam:
|
||||||
try:
|
try:
|
||||||
gewenst = int(f.get("default") or 0)
|
gewenst = int(f.get("default") or 0)
|
||||||
|
|
@ -1466,10 +1516,11 @@ def api_store_install():
|
||||||
net_ip = (d.get("ip") or "").strip()
|
net_ip = (d.get("ip") or "").strip()
|
||||||
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
|
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
|
||||||
# kan bereiken (en andersom).
|
# kan bereiken (en andersom).
|
||||||
verbinden = d.get("connect_apps")
|
# Aan welke gedeelde netwerken deze stack meedoet. Eerder was dit één
|
||||||
if verbinden is None:
|
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
|
||||||
verbinden = bool(cfg.load().get("CONNECT_APPS_DEFAULT", True))
|
|
||||||
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
|
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:
|
if not stack:
|
||||||
return jsonify(ok=False, msg="stack vereist")
|
return jsonify(ok=False, msg="stack vereist")
|
||||||
|
|
||||||
|
|
@ -1576,9 +1627,18 @@ def api_store_install():
|
||||||
jobs.log(qq, "error", str(e))
|
jobs.log(qq, "error", str(e))
|
||||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
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
|
# Wachtwoorden en sleutels naar .env, zodat het compose-bestand
|
||||||
# te delen is en de geheimen op één plek met 0600 staan.
|
# te delen is en de geheimen op één plek met 0600 staan.
|
||||||
cf_render = docker.find_compose(dest)
|
|
||||||
if cf_render:
|
if cf_render:
|
||||||
try:
|
try:
|
||||||
nieuw, env_geheim, koppeling = compose_transform.geheimen_naar_env(
|
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
|
# Aan het gedeelde netwerk hangen. Zonder dit zit elke stack in
|
||||||
# zijn eigen compose-netwerk en vindt geen enkele app een andere.
|
# zijn eigen compose-netwerk en vindt geen enkele app een andere.
|
||||||
if verbinden and gedeeld_net:
|
if app_netten:
|
||||||
ok_net, fout_net = networks.ensure_shared(gedeeld_net)
|
klaar = []
|
||||||
if not ok_net:
|
for naam in app_netten:
|
||||||
jobs.log(qq, "warn",
|
ok_net, fout_net = networks.ensure_shared(naam)
|
||||||
f"Gedeeld netwerk '{gedeeld_net}' niet beschikbaar: "
|
if ok_net:
|
||||||
f"{fout_net} — de stack start zonder koppeling.")
|
klaar.append(naam)
|
||||||
else:
|
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)
|
cf = docker.find_compose(dest)
|
||||||
try:
|
try:
|
||||||
cf.write_text(compose_transform.add_shared_network(
|
cf.write_text(compose_transform.add_shared_network(
|
||||||
cf.read_text(encoding="utf-8"), gedeeld_net),
|
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
|
||||||
encoding="utf-8")
|
|
||||||
jobs.log(qq, "ok",
|
jobs.log(qq, "ok",
|
||||||
f"Gekoppeld aan netwerk '{gedeeld_net}' — andere "
|
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
|
||||||
"apps zijn bereikbaar op hun containernaam.")
|
+ " — apps daarin zijn bereikbaar op hun containernaam.")
|
||||||
except compose_transform.TransformError as e:
|
except compose_transform.TransformError as e:
|
||||||
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
|
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
|
||||||
|
|
||||||
|
|
@ -1727,7 +1790,8 @@ def api_store_install():
|
||||||
# geen andere installatie hetzelfde IP voorgesteld.
|
# geen andere installatie hetzelfde IP voorgesteld.
|
||||||
"network": net_name,
|
"network": net_name,
|
||||||
"ip": net_ip if net else "",
|
"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 [],
|
"categories": meta.get("categories") or [],
|
||||||
"tags": meta.get("tags") or [],
|
"tags": meta.get("tags") or [],
|
||||||
# Zonder de gebruikte waarden valt het installatieformulier
|
# Zonder de gebruikte waarden valt het installatieformulier
|
||||||
|
|
@ -1975,6 +2039,210 @@ def api_disk():
|
||||||
return jsonify(diskspace.status())
|
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) ─────────────────────────────────────
|
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
|
||||||
|
|
||||||
def _network_by_name(name: str) -> dict | None:
|
def _network_by_name(name: str) -> dict | None:
|
||||||
|
|
@ -2425,7 +2693,11 @@ _SETTINGS_HIDE = {"WIZARD_DONE", "ACTIVE_MODULES"}
|
||||||
@app.route("/api/settings")
|
@app.route("/api/settings")
|
||||||
def api_settings():
|
def api_settings():
|
||||||
c = cfg.redact(cfg.load())
|
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"])
|
@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:
|
if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS:
|
||||||
return jsonify(ok=False, msg="Onbekend update-kanaal"), 400
|
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 "NOTIFY_CHANNEL" in updates:
|
||||||
if (updates.get("NOTIFY_CHANNEL") or "") not in notify.KANALEN:
|
if (updates.get("NOTIFY_CHANNEL") or "") not in notify.KANALEN:
|
||||||
return jsonify(ok=False, msg="Onbekend meldingskanaal"), 400
|
return jsonify(ok=False, msg="Onbekend meldingskanaal"), 400
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ DEFAULTS = {
|
||||||
"ACTIVE_MODULES": None,
|
"ACTIVE_MODULES": None,
|
||||||
"MODULE_SETTINGS": {},
|
"MODULE_SETTINGS": {},
|
||||||
"LANGUAGE": "nl",
|
"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",
|
"THEME": "dark",
|
||||||
"WIZARD_DONE": False,
|
"WIZARD_DONE": False,
|
||||||
# Update-check: Forgejo/GitHub Releases-API die de laatste release teruggeeft.
|
# Update-check: Forgejo/GitHub Releases-API die de laatste release teruggeeft.
|
||||||
|
|
@ -61,6 +68,10 @@ DEFAULTS = {
|
||||||
# geen andere app bereiken.
|
# geen andere app bereiken.
|
||||||
"SHARED_NETWORK": "serverup",
|
"SHARED_NETWORK": "serverup",
|
||||||
"CONNECT_APPS_DEFAULT": True,
|
"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
|
# Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup
|
||||||
# van een stack wordt nooit opgeruimd.
|
# van een stack wordt nooit opgeruimd.
|
||||||
"BACKUP_KEEP": 5,
|
"BACKUP_KEEP": 5,
|
||||||
|
|
@ -96,6 +107,12 @@ DEFAULTS = {
|
||||||
"NOTIFY_EMAIL_FROM": "",
|
"NOTIFY_EMAIL_FROM": "",
|
||||||
"NOTIFY_EMAIL_TO": "",
|
"NOTIFY_EMAIL_TO": "",
|
||||||
"NOTIFY_EMAIL_SECURITY": "starttls", # starttls | ssl | geen
|
"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).
|
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
|
||||||
"SCHEDULER_LAST": {},
|
"SCHEDULER_LAST": {},
|
||||||
# Updatecheck voor de geïnstalleerde stacks (image-digests).
|
# 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
|
# Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan
|
||||||
# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens.
|
# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens.
|
||||||
WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN", "NOTIFY_TOKEN",
|
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"))
|
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
|
||||||
_lock = threading.RLock()
|
_lock = threading.RLock()
|
||||||
|
|
@ -143,6 +160,47 @@ def _sanitize_repos(repos: list) -> list:
|
||||||
return repos
|
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:
|
def safe_name(name: str) -> str | None:
|
||||||
"""Valideer een stack-/instantienaam. Retourneert de naam of None.
|
"""Valideer een stack-/instantienaam. Retourneert de naam of None.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,11 @@ def to_dedicated_ip(compose_text: str, network: str, ip: str,
|
||||||
allow_unicode=True, width=120)
|
allow_unicode=True, width=120)
|
||||||
|
|
||||||
|
|
||||||
def add_shared_network(compose_text: str, network: str) -> str:
|
def add_shared_network(compose_text: str, network) -> str:
|
||||||
"""Hang elke service ook aan een gedeeld netwerk.
|
"""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
|
Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen
|
||||||
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken — een
|
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):
|
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
|
||||||
raise TransformError("compose bevat geen services")
|
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
|
aangesloten = 0
|
||||||
for svc in doc["services"].values():
|
for svc in doc["services"].values():
|
||||||
if not isinstance(svc, dict):
|
if not isinstance(svc, dict):
|
||||||
|
|
@ -103,14 +111,15 @@ def add_shared_network(compose_text: str, network: str) -> str:
|
||||||
if svc.get("network_mode"):
|
if svc.get("network_mode"):
|
||||||
continue
|
continue
|
||||||
# `networks:` mag een lijst of een mapping zijn; allebei aanvullen.
|
# `networks:` mag een lijst of een mapping zijn; allebei aanvullen.
|
||||||
|
for naam in namen:
|
||||||
huidig = svc.get("networks")
|
huidig = svc.get("networks")
|
||||||
if huidig is None:
|
if huidig is None:
|
||||||
svc["networks"] = [network]
|
svc["networks"] = [naam]
|
||||||
elif isinstance(huidig, list):
|
elif isinstance(huidig, list):
|
||||||
if network not in huidig:
|
if naam not in huidig:
|
||||||
huidig.append(network)
|
huidig.append(naam)
|
||||||
elif isinstance(huidig, dict):
|
elif isinstance(huidig, dict):
|
||||||
huidig.setdefault(network, None)
|
huidig.setdefault(naam, None)
|
||||||
aangesloten += 1
|
aangesloten += 1
|
||||||
|
|
||||||
# Sluit geen enkele service aan, dan hoeft het netwerk ook niet gedeclareerd
|
# 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")
|
netwerken = doc.get("networks")
|
||||||
if not isinstance(netwerken, dict):
|
if not isinstance(netwerken, dict):
|
||||||
netwerken = {}
|
netwerken = {}
|
||||||
netwerken.setdefault(network, {"external": True})
|
for naam in namen:
|
||||||
|
netwerken.setdefault(naam, {"external": True})
|
||||||
doc["networks"] = netwerken
|
doc["networks"] = netwerken
|
||||||
|
|
||||||
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||||
allow_unicode=True, width=120)
|
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],
|
def geheimen_naar_env(compose_text: str, velden: list[dict],
|
||||||
waarden: dict) -> tuple[str, dict[str, str], dict[str, str]]:
|
waarden: dict) -> tuple[str, dict[str, str], dict[str, str]]:
|
||||||
"""Haal wachtwoorden en sleutels uit het compose-bestand naar `.env`.
|
"""Haal wachtwoorden en sleutels uit het compose-bestand naar `.env`.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import socket
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import core as cfg
|
||||||
from core import docker
|
from core import docker
|
||||||
|
|
||||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
|
_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", [])]
|
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 ────────────────────────────────────────────────────────────
|
# ── IP-toewijzing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def next_free_ip(net: dict, extra_used: set[str] | None = None) -> str:
|
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, ""
|
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]:
|
def remove(name: str) -> tuple[bool, str]:
|
||||||
if not _NAME_RE.match(name or ""):
|
if not _NAME_RE.match(name or ""):
|
||||||
return False, "Ongeldige netwerknaam."
|
return False, "Ongeldige netwerknaam."
|
||||||
|
|
|
||||||
206
server-up/core/pangolin.py
Normal file
206
server-up/core/pangolin.py
Normal 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}
|
||||||
|
|
@ -883,6 +883,55 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
<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('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('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('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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -1432,16 +1494,39 @@ tailwind.config = {
|
||||||
<!-- Verbinden: netwerk en koppelingen -->
|
<!-- Verbinden: netwerk en koppelingen -->
|
||||||
<template x-if="s.id === 'verbinden'">
|
<template x-if="s.id === 'verbinden'">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<label class="flex items-start gap-2.5 cursor-pointer">
|
<!-- Meerdere netwerken: niet elke app hoeft elke andere te
|
||||||
<input type="checkbox" :checked="install.connect_apps !== false"
|
kunnen bereiken. -->
|
||||||
@change="install.connect_apps = $event.target.checked"
|
<div>
|
||||||
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
<div class="form-label" x-text="t('app_networks')">Gedeelde netwerken</div>
|
||||||
<span class="min-w-0">
|
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('app_networks_hint')"></p>
|
||||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
|
<div class="space-y-1.5">
|
||||||
<span class="block text-[11px] text-dm dark:text-dm-dark"
|
<template x-for="n in appNets" :key="n.name">
|
||||||
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
|
<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>
|
||||||
|
<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>
|
</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>
|
<div>
|
||||||
<label class="form-label" x-text="t('network')">Netwerk</label>
|
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||||
|
|
@ -1453,7 +1538,27 @@ tailwind.config = {
|
||||||
<template x-if="install.network">
|
<template x-if="install.network">
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
|
<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>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1652,7 +1757,7 @@ tailwind.config = {
|
||||||
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
|
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" x-show="wizardActief() && !opLaatsteStap()"
|
<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>
|
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
|
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
|
||||||
|
|
@ -1739,6 +1844,23 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<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 class="mdi mdi-information-outline shrink-0"></span>
|
||||||
<span x-text="t('no_credentials_hint')"></span>
|
<span x-text="t('no_credentials_hint')"></span>
|
||||||
|
|
@ -1922,6 +2044,9 @@ function app() {
|
||||||
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
|
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
|
||||||
// Invulmenu: keuzes die over installaties heen blijven gelden.
|
// Invulmenu: keuzes die over installaties heen blijven gelden.
|
||||||
showAdvanced: false, onePage: false, revealed: {},
|
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: {}},
|
creds: {stack: '', address: '', items: [], self_signup: false, reveal: {}},
|
||||||
notifyTesting: false,
|
notifyTesting: false,
|
||||||
stackUpd: {}, checkingUpd: false,
|
stackUpd: {}, checkingUpd: false,
|
||||||
|
|
@ -2391,6 +2516,69 @@ function app() {
|
||||||
this.termOpen = true;
|
this.termOpen = true;
|
||||||
this.pollJob(d.job_id, () => this.loadBackups(this.bkStack));
|
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 ────────────────────────────────────────────────────────────
|
// ── Meldingen ────────────────────────────────────────────────────────────
|
||||||
async loadNotify() {
|
async loadNotify() {
|
||||||
this.notify = await this.rpc('/api/notify');
|
this.notify = await this.rpc('/api/notify');
|
||||||
|
|
@ -2713,6 +2901,14 @@ function app() {
|
||||||
this.install.preview_files = d.preview_files || {};
|
this.install.preview_files = d.preview_files || {};
|
||||||
this.install.step = 0;
|
this.install.step = 0;
|
||||||
this.revealed = {};
|
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 {
|
} else {
|
||||||
this.install.envVars = d.env_vars || [];
|
this.install.envVars = d.env_vars || [];
|
||||||
this.install.images = d.images || [];
|
this.install.images = d.images || [];
|
||||||
|
|
@ -2859,7 +3055,9 @@ function app() {
|
||||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||||||
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
|
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
|
||||||
repo_id: d.repo_id || '',
|
repo_id: d.repo_id || '',
|
||||||
|
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
|
||||||
};
|
};
|
||||||
|
await this.loadAppNets(stack);
|
||||||
this.fieldInfo = null;
|
this.fieldInfo = null;
|
||||||
this.revealed = {};
|
this.revealed = {};
|
||||||
this.modal = 'install';
|
this.modal = 'install';
|
||||||
|
|
@ -2872,6 +3070,7 @@ function app() {
|
||||||
network: this.install.network,
|
network: this.install.network,
|
||||||
ip: this.install.ip,
|
ip: this.install.ip,
|
||||||
connect_apps: this.install.connect_apps !== false,
|
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; }
|
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
|
|
@ -2895,6 +3094,7 @@ function app() {
|
||||||
};
|
};
|
||||||
if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; }
|
if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; }
|
||||||
body.connect_apps = this.install.connect_apps !== false;
|
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;
|
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
||||||
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
|
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
|
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
|
||||||
|
|
@ -2960,6 +3160,7 @@ function app() {
|
||||||
await this.loadUsers();
|
await this.loadUsers();
|
||||||
await this.loadNetworks();
|
await this.loadNetworks();
|
||||||
await this.loadNotify();
|
await this.loadNotify();
|
||||||
|
await this.loadPangolin();
|
||||||
},
|
},
|
||||||
// Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
|
// Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
|
||||||
// een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.
|
// een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.
|
||||||
|
|
|
||||||
|
|
@ -317,5 +317,30 @@
|
||||||
"step_network": "Connect",
|
"step_network": "Connect",
|
||||||
"step_settings": "Settings",
|
"step_settings": "Settings",
|
||||||
"step_secrets": "Access",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -317,5 +317,30 @@
|
||||||
"step_network": "Verbinden",
|
"step_network": "Verbinden",
|
||||||
"step_settings": "Instellingen",
|
"step_settings": "Instellingen",
|
||||||
"step_secrets": "Toegang",
|
"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
101
tests/js/modal.dom.test.js
Normal 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
9
tests/js/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
318
tests/test_netwerk_advies.py
Normal file
318
tests/test_netwerk_advies.py
Normal 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
274
tests/test_pangolin.py
Normal 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
|
||||||
|
|
@ -21,6 +21,21 @@ def test_invulmenu_logica():
|
||||||
assert r.returncode == 0, r.stdout + r.stderr
|
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")
|
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
|
||||||
def test_javascript_van_de_pagina_parseert():
|
def test_javascript_van_de_pagina_parseert():
|
||||||
"""Een typefout in de component maakt de hele interface blanco; dat wil je
|
"""Een typefout in de component maakt de hele interface blanco; dat wil je
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue