v0.5.00-beta - containerbeheer, eigen IP-adressen, veiliger installeren
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 3s
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 3s
- Beheer per container: de stackkaart klapt uit naar de losse containers, met start/stop/herstart, logs en live CPU-/geheugengebruik per container. De containernaam wordt getoetst aan compose_ps van díé stack, zodat de route geen willekeurige container op de host kan raken. - Stacks met een eigen IP-adres (macvlan/ipvlan): netwerkbeheer onder Instellingen → Netwerken, netwerkkeuze in de installatiemodal met voorstel voor het eerstvolgende vrije adres, en automatische omzetting van het gerenderde compose-bestand (poortmappings eruit, ipv4_address erin). De templates in apps/ blijven ongewijzigd. - Toegekende IP's worden vastgehouden in .serverup.json en getoond op de stackkaart, zodat een volgende installatie ze niet opnieuw uitdeelt. - Vrije poort voorstellen bij installeren: next_free_port() bestond al maar werd nergens gebruikt. Bezette poorten worden in de UI gemeld. - Genereerknop voor velden die op een geheim wijzen (token/password/secret), lokaal gegenereerd via crypto.getRandomValues. - Compose valideren met `docker compose config` vóór het wegschrijven, zowel bij de editor als na het renderen bij installatie. Ontbreekt de compose-CLI, dan blokkeert dat een installatie niet. - Uitloggen in de zijbalk; gebruikersbeheer en wachtwoord wijzigen onder Instellingen → Beveiliging. - docs/netwerken.md (incl. de shim-interface die de host nodig heeft om zijn eigen macvlan-containers te bereiken) en docs/beveiliging.md toegevoegd. - CHANGELOG bijgewerkt; testsuite uitgebreid naar 112 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
parent
916e54a229
commit
8b29553a71
11 changed files with 1445 additions and 14 deletions
87
CHANGELOG.md
87
CHANGELOG.md
|
|
@ -1,3 +1,90 @@
|
||||||
|
# v0.5.00-beta — Authenticatie, beveiliging en eigen IP-adressen
|
||||||
|
|
||||||
|
> **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het
|
||||||
|
> bijwerken meteen de webinterface en maak een beheerdersaccount aan — zolang
|
||||||
|
> dat niet gebeurd is, kan iedereen die de pagina bereikt het account claimen.
|
||||||
|
> De poort wordt voortaan standaard op `127.0.0.1` gebonden; zet `BIND=0.0.0.0`
|
||||||
|
> in je `.env` als je er van buiten de server bij moet (liefst achter een
|
||||||
|
> reverse proxy met TLS — zie `docs/beveiliging.md`).
|
||||||
|
|
||||||
|
## 🔐 Authenticatie
|
||||||
|
|
||||||
|
Tot nu toe was elke `/api/*`-route open. Omdat Server Up de docker-socket als
|
||||||
|
root gebruikt, betekende dat: wie de poort kon bereiken, had root op de host.
|
||||||
|
|
||||||
|
- Lokale accounts met scrypt-gehashte wachtwoorden, sessiecookie
|
||||||
|
(`HttpOnly`, `SameSite=Strict`) en lockout na vijf mislukte pogingen.
|
||||||
|
- Optionele SSO via een reverse-proxy-header (Authelia/Authentik/Cloudflare
|
||||||
|
Access). De header wordt alleen vertrouwd vanaf een geconfigureerd proxy-IP.
|
||||||
|
- Loginscherm en eerste-account-setup in de interface; gebruikersbeheer en
|
||||||
|
wachtwoord wijzigen onder Instellingen → Beveiliging.
|
||||||
|
|
||||||
|
## 🛡️ Beveiligingsfixes
|
||||||
|
|
||||||
|
- **CSRF**: elke mutatie vereist een `X-CSRF-Token`-header. Routes die de
|
||||||
|
toestand wijzigen accepteren geen `GET` meer — `/api/docker/restart` was
|
||||||
|
eerder met een `<img>`-tag vanaf een willekeurige website te triggeren.
|
||||||
|
- **Path traversal**: `/api/store/install` controleerde de instantienaam niet;
|
||||||
|
`"instance": "../../…"` schreef buiten de library. Alle stack-, instantie- en
|
||||||
|
repo-namen lopen nu door één `safe_name()`-validatie.
|
||||||
|
- **Tokenlek**: git-tokens werden teruggegeven door `/api/repos` en
|
||||||
|
`/api/settings`. Die zijn vervangen door een `has_token`-vlag; opslaan met een
|
||||||
|
leeg veld wist het bestaande token niet meer.
|
||||||
|
- **Git-URL's**: alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's
|
||||||
|
`ext::`-transport voert een shell-commando uit en wordt nu geweigerd.
|
||||||
|
- **Templates** renderen in een Jinja2-sandbox (server-side template injection).
|
||||||
|
- **SSH**: host-keys worden geverifieerd (`accept-new` + `/data/known_hosts`);
|
||||||
|
eerder stond `StrictHostKeyChecking=no`, waarmee elke MITM onzichtbaar was.
|
||||||
|
- **Modules**: repo's worden niet meer automatisch bij elke start gepulld
|
||||||
|
(`AUTO_SYNC_ON_BOOT`, standaard uit) en kunnen per repo op een commit worden
|
||||||
|
vastgezet.
|
||||||
|
- Productie-WSGI-server (waitress) i.p.v. de Flask-ontwikkelserver, limiet op
|
||||||
|
request-grootte, `ProxyFix`, en CSP/`X-Frame-Options`/`nosniff`/
|
||||||
|
`Referrer-Policy`-headers.
|
||||||
|
- Tailwind, Alpine en htmx worden meegeleverd in plaats van vanaf een CDN
|
||||||
|
geladen; Google Fonts is eruit. De interface werkt nu ook offline.
|
||||||
|
- `config.json` en de sleutel staan op 0600.
|
||||||
|
|
||||||
|
## 🌐 Stacks met een eigen IP-adres
|
||||||
|
|
||||||
|
Nieuw: geef een stack een eigen adres in je LAN in plaats van poorten op de host
|
||||||
|
(macvlan/ipvlan). Geen poortconflicten meer, apps op hun eigen standaardpoort, en
|
||||||
|
je kunt per app firewallen.
|
||||||
|
|
||||||
|
- Netwerkbeheer onder Instellingen → Netwerken (driver, host-interface, subnet,
|
||||||
|
gateway, optionele IP-range).
|
||||||
|
- Bij het installeren kies je "Poorten op de host" of een netwerk; Server Up
|
||||||
|
stelt het eerstvolgende vrije adres voor en houdt toegekende adressen vast.
|
||||||
|
- Het gerenderde compose-bestand wordt automatisch omgezet: poortmappings eruit,
|
||||||
|
netwerk met `ipv4_address` erin. De templates in `apps/` blijven ongewijzigd.
|
||||||
|
- Uitleg en valkuilen (waaronder de shim-interface die de host nodig heeft om
|
||||||
|
zijn eigen macvlan-containers te bereiken) staan in `docs/netwerken.md`.
|
||||||
|
|
||||||
|
## 🧩 Beheer per container
|
||||||
|
|
||||||
|
De stackkaart klapt uit naar de losse containers: per container starten, stoppen,
|
||||||
|
herstarten, logs bekijken en live CPU-/geheugengebruik.
|
||||||
|
|
||||||
|
## 📦 Veiliger installeren
|
||||||
|
|
||||||
|
- Poortvelden krijgen een vrij poortnummer voorgesteld — `next_free_port()`
|
||||||
|
bestond al maar werd nergens gebruikt. Bezette poorten worden gemeld.
|
||||||
|
- Velden voor tokens en wachtwoorden krijgen een genereerknop.
|
||||||
|
- Compose wordt gevalideerd (`docker compose config`) vóór het wegschrijven, dus
|
||||||
|
een typefout in de editor maakt een draaiende stack niet meer onstartbaar.
|
||||||
|
|
||||||
|
## 🔧 Overig
|
||||||
|
|
||||||
|
- Testsuite met pytest (112 tests), ook als stap in beide deploy-workflows.
|
||||||
|
- Audit-log gebruikt één gedeelde SQLite-verbinding — elke job lekte eerder een
|
||||||
|
file descriptor. Joblogs worden afgekapt op 2000 regels.
|
||||||
|
- Het `VERSION`-bestand is de enige bron voor het versienummer.
|
||||||
|
- Lichte `/healthz` voor de healthcheck in plaats van `docker info`.
|
||||||
|
- `fix-config.sh` verwijderd: bevatte een hardgecodeerd intern IP en
|
||||||
|
overschreef de configuratie van de gebruiker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.4.60 — Tweecijferig patch-nummer
|
# v0.4.60 — Tweecijferig patch-nummer
|
||||||
|
|
||||||
## Versiebeleid vanaf v0.4.60
|
## Versiebeleid vanaf v0.4.60
|
||||||
|
|
|
||||||
111
docs/beveiliging.md
Normal file
111
docs/beveiliging.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Beveiliging
|
||||||
|
|
||||||
|
Server Up beheert de Docker-daemon. Wie toegang heeft tot de webinterface, kan
|
||||||
|
containers starten met willekeurige volumes — en daarmee in de praktijk alles op
|
||||||
|
de host. Behandel toegang tot Server Up dus als root-toegang tot de server.
|
||||||
|
|
||||||
|
Vanaf v0.5.00 is de interface standaard afgeschermd met een login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eerste start
|
||||||
|
|
||||||
|
Bij de eerste start is er nog geen account. Open de webinterface en maak er
|
||||||
|
meteen een aan: **zolang dat niet gebeurd is, kan iedereen die de pagina bereikt
|
||||||
|
het beheerdersaccount claimen.** De container laat daarom bij het opstarten een
|
||||||
|
waarschuwing zien.
|
||||||
|
|
||||||
|
Standaard bindt `docker-compose.yml` de poort op `127.0.0.1`, dus alleen vanaf de
|
||||||
|
server zelf bereikbaar. Wil je er van buitenaf bij, zet dan een reverse proxy met
|
||||||
|
TLS ervoor (zie onder) en pas `BIND` aan in je `.env`.
|
||||||
|
|
||||||
|
## Authenticatiemodi
|
||||||
|
|
||||||
|
Instelbaar via `PUT /api/auth/mode`:
|
||||||
|
|
||||||
|
| Modus | Betekenis |
|
||||||
|
|-------|-----------|
|
||||||
|
| `local` (standaard) | Gebruikersnaam + wachtwoord in Server Up zelf |
|
||||||
|
| `proxy` | Identiteit komt uit een header van je reverse proxy; geen lokale login meer |
|
||||||
|
| `both` | Beide; handig om SSO te testen zonder jezelf buiten te sluiten |
|
||||||
|
|
||||||
|
Wachtwoorden worden opgeslagen als scrypt-hash (n=2¹⁴) met een willekeurige salt.
|
||||||
|
Na vijf mislukte pogingen is het account vijf minuten geblokkeerd.
|
||||||
|
|
||||||
|
### SSO via een reverse proxy
|
||||||
|
|
||||||
|
Draai je Authelia, Authentik of Cloudflare Access, dan kan die de identiteit in
|
||||||
|
een header zetten (meestal `Remote-User`).
|
||||||
|
|
||||||
|
Belangrijk: die header wordt **alleen** vertrouwd als het bron-IP in
|
||||||
|
`trusted_proxies` staat. Zonder die lijst kan iedereen de header zelf meesturen
|
||||||
|
en zich voordoen als beheerder. Server Up weigert daarom modus `proxy`/`both` als
|
||||||
|
`trusted_proxies` leeg is.
|
||||||
|
|
||||||
|
Zorg er ook voor dat je proxy de header van binnenkomende requests **wist** en
|
||||||
|
zelf opnieuw zet.
|
||||||
|
|
||||||
|
## Reverse proxy met TLS
|
||||||
|
|
||||||
|
Server Up spreekt gewoon HTTP. Zet er een proxy voor die TLS afhandelt:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Remote-User ""; # wis wat de client stuurt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zet `SU_HTTPS=1` in de omgeving zodra alles via TLS loopt; de sessiecookie krijgt
|
||||||
|
dan de `Secure`-vlag.
|
||||||
|
|
||||||
|
## Wat er beveiligd is
|
||||||
|
|
||||||
|
- **Sessies** — `HttpOnly`, `SameSite=Strict`, 12 uur geldig.
|
||||||
|
- **CSRF** — elke POST/PUT/DELETE vereist de `X-CSRF-Token`-header. Routes die de
|
||||||
|
toestand wijzigen accepteren geen GET meer.
|
||||||
|
- **Padvalidatie** — stack-, instantie- en repo-namen worden gecontroleerd, zodat
|
||||||
|
ze nooit buiten `LIBRARY_DIR` of de git-cache kunnen wijzen.
|
||||||
|
- **Git-URL's** — alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's
|
||||||
|
`ext::`-transport voert een shell-commando uit en wordt geweigerd.
|
||||||
|
- **Templates** — boilerplates renderen in een Jinja2-sandbox.
|
||||||
|
- **Geheimen** — git-tokens worden nooit teruggegeven door de API (alleen een
|
||||||
|
`has_token`-vlag); `config.json` en `secret.key` staan op 0600.
|
||||||
|
- **Headers** — CSP op `'self'`, `X-Frame-Options: DENY`, `nosniff`,
|
||||||
|
`Referrer-Policy: no-referrer`. Alle front-end libraries worden meegeleverd,
|
||||||
|
dus er gaat op runtime niets naar een CDN.
|
||||||
|
- **SSH** — host-keys worden geverifieerd (`accept-new`, opgeslagen in
|
||||||
|
`/data/known_hosts`).
|
||||||
|
|
||||||
|
## Externe repo's en modules
|
||||||
|
|
||||||
|
Een module is Python-code die Server Up **uitvoert**. Voeg alleen repo's toe die
|
||||||
|
je vertrouwt. Twee knoppen om aan te draaien:
|
||||||
|
|
||||||
|
- `AUTO_SYNC_ON_BOOT` staat standaard **uit**. Repo's worden dus niet vanzelf
|
||||||
|
bijgewerkt; je synchroniseert zelf wanneer je dat wilt.
|
||||||
|
- Zet per repo een `commit` in de config om hem op een specifieke commit vast te
|
||||||
|
zetten:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wat níét is afgedekt
|
||||||
|
|
||||||
|
- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat
|
||||||
|
is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints
|
||||||
|
toelaat werkt niet, omdat compose vrijwel alles nodig heeft.
|
||||||
|
- **Rechten per gebruiker.** Elk account heeft dezelfde volledige toegang; er
|
||||||
|
zijn geen rollen.
|
||||||
|
- **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een
|
||||||
|
proxy met rate limiting voor.
|
||||||
|
|
||||||
|
## Een probleem melden
|
||||||
|
|
||||||
|
Vind je een beveiligingsprobleem, meld het dan via de repository-issues met zo
|
||||||
|
veel mogelijk details over de reproductie.
|
||||||
134
docs/netwerken.md
Normal file
134
docs/netwerken.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# Stacks een eigen IP-adres geven (macvlan / ipvlan)
|
||||||
|
|
||||||
|
Standaard publiceert een stack poorten op de host: Vaultwarden op `8222`, Mealie
|
||||||
|
op `9925`, enzovoort. Dat werkt, maar levert op den duur een lange lijst
|
||||||
|
poortnummers op die je moet onthouden, en twee apps die allebei poort 8080 willen
|
||||||
|
gaan niet samen.
|
||||||
|
|
||||||
|
Met een **macvlan**- of **ipvlan**-netwerk krijgt een stack een eigen IP-adres in
|
||||||
|
je LAN. De app draait dan gewoon op zijn eigen standaardpoort:
|
||||||
|
|
||||||
|
| Zonder | Met eigen IP |
|
||||||
|
|--------|--------------|
|
||||||
|
| `http://192.168.1.10:8222` | `http://192.168.1.240` |
|
||||||
|
| `http://192.168.1.10:9925` | `http://192.168.1.241` |
|
||||||
|
|
||||||
|
Je kunt er ook per app op firewallen, en een DNS-naam aan koppelen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Lees dit eerst: de host bereikt zijn eigen containers niet
|
||||||
|
|
||||||
|
Dit is de bekendste valkuil van macvlan. De Docker-host kan **niet** bij een
|
||||||
|
container die op een macvlan-netwerk draait, en andersom ook niet. Alle andere
|
||||||
|
apparaten in je netwerk kunnen dat wél.
|
||||||
|
|
||||||
|
Draait Server Up op dezelfde machine als de containers, dan zie je dus:
|
||||||
|
|
||||||
|
- vanaf je laptop: `http://192.168.1.240` werkt
|
||||||
|
- vanaf de server zelf: `curl http://192.168.1.240` loopt vast
|
||||||
|
|
||||||
|
Heb je dat nodig (bijvoorbeeld voor een healthcheck of een reverse proxy die op
|
||||||
|
de host draait), maak dan een shim-interface aan op de host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Eenmalig; vervang eth0 en de adressen door die van jouw netwerk.
|
||||||
|
ip link add shim link eth0 type macvlan mode bridge
|
||||||
|
ip addr add 192.168.1.239/32 dev shim
|
||||||
|
ip link set shim up
|
||||||
|
ip route add 192.168.1.240/28 dev shim
|
||||||
|
```
|
||||||
|
|
||||||
|
Zet dat in een systemd-unit of in `/etc/network/interfaces`, anders is het na een
|
||||||
|
herstart weg.
|
||||||
|
|
||||||
|
**ipvlan (l2)** heeft dit probleem niet op dezelfde manier, maar vereist wel dat
|
||||||
|
je switch en router er goed mee omgaan. Werkt macvlan niet, probeer dan ipvlan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stap 1 — Bepaal je netwerkgegevens
|
||||||
|
|
||||||
|
Je hebt vier dingen nodig:
|
||||||
|
|
||||||
|
| Wat | Voorbeeld | Hoe kom je eraan |
|
||||||
|
|-----|-----------|------------------|
|
||||||
|
| Host-interface | `eth0` | `ip -br link` op de server |
|
||||||
|
| Subnet | `192.168.1.0/24` | Je router; hetzelfde subnet als de server |
|
||||||
|
| Gateway | `192.168.1.1` | `ip route \| grep default` |
|
||||||
|
| IP-range | `192.168.1.240/28` | Een blok dat je **buiten je DHCP-bereik** houdt |
|
||||||
|
|
||||||
|
De IP-range is optioneel maar sterk aanbevolen. Zonder range mag Docker het hele
|
||||||
|
subnet gebruiken en kan het een adres uitdelen dat je DHCP-server ook aan een
|
||||||
|
telefoon geeft. `192.168.1.240/28` reserveert `.240` t/m `.255` — zestien
|
||||||
|
adressen. Zet dat bereik in je router buiten de DHCP-pool.
|
||||||
|
|
||||||
|
## Stap 2 — Netwerk aanmaken in Server Up
|
||||||
|
|
||||||
|
Ga naar **Instellingen → Netwerken → Netwerk** en vul de gegevens in. Server Up
|
||||||
|
maakt het Docker-netwerk aan en onthoudt de definitie.
|
||||||
|
|
||||||
|
Hetzelfde met de hand zou zijn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create -d macvlan \
|
||||||
|
--subnet 192.168.1.0/24 \
|
||||||
|
--gateway 192.168.1.1 \
|
||||||
|
--ip-range 192.168.1.240/28 \
|
||||||
|
-o parent=eth0 \
|
||||||
|
lan
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stap 3 — Een stack installeren op een eigen IP
|
||||||
|
|
||||||
|
Kies in de installatiemodal bij **Netwerk** je netwerk in plaats van "Poorten op
|
||||||
|
de host". Server Up stelt het eerstvolgende vrije adres voor; je kunt het
|
||||||
|
overschrijven.
|
||||||
|
|
||||||
|
Bij het installeren gebeurt er dit met het compose-bestand:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# vóór # ná
|
||||||
|
services: services:
|
||||||
|
vaultwarden: vaultwarden:
|
||||||
|
image: vaultwarden/server image: vaultwarden/server
|
||||||
|
ports: networks:
|
||||||
|
- "8222:80" lan:
|
||||||
|
ipv4_address: 192.168.1.240
|
||||||
|
networks:
|
||||||
|
lan:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
De poortmapping verdwijnt — die heeft geen functie meer. Vaultwarden luistert op
|
||||||
|
poort 80 en is bereikbaar op `http://192.168.1.240`.
|
||||||
|
|
||||||
|
Bestaat een stack uit meerdere containers, dan krijgt de container die poorten
|
||||||
|
publiceerde het vaste adres; de rest komt zonder vast adres in hetzelfde netwerk,
|
||||||
|
zodat ze elkaar op servicenaam blijven vinden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adresbeheer
|
||||||
|
|
||||||
|
Server Up bewaart het toegekende adres in `.serverup.json` in de stackmap. Zo
|
||||||
|
blijft het gereserveerd, ook als de stack gestopt is, en krijgt een volgende
|
||||||
|
installatie het volgende vrije adres voorgesteld.
|
||||||
|
|
||||||
|
Wil je het adres van een bestaande stack wijzigen, pas dan het compose-bestand
|
||||||
|
aan via **compose bewerken** en werk `ipv4_address` bij.
|
||||||
|
|
||||||
|
## Verwijderen
|
||||||
|
|
||||||
|
Een netwerk kan pas weg als er geen containers meer op draaien. Stop eerst de
|
||||||
|
betreffende stacks; Server Up weigert het anders met een melding.
|
||||||
|
|
||||||
|
## Problemen oplossen
|
||||||
|
|
||||||
|
| Symptoom | Oorzaak |
|
||||||
|
|----------|---------|
|
||||||
|
| `network ... not found` bij het starten | Netwerk verwijderd terwijl de stack er nog naar verwijst — maak het opnieuw aan |
|
||||||
|
| Container krijgt geen verbinding | Verkeerde `parent`-interface, of de interface zit in een bond/bridge |
|
||||||
|
| Adres al in gebruik | Het adres valt binnen je DHCP-bereik; verklein de DHCP-pool of kies een andere range |
|
||||||
|
| Vanaf de server niet bereikbaar, vanaf laptop wel | Verwacht gedrag — zie de shim-interface bovenaan |
|
||||||
|
| Werkt niet op WiFi | Klopt: macvlan werkt niet over een draadloze interface |
|
||||||
268
server-up/app.py
268
server-up/app.py
|
|
@ -15,6 +15,7 @@ from flask import Flask, Response, jsonify, render_template, request, session
|
||||||
import core as cfg
|
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 compose_transform, networks
|
||||||
from core.modules import Module, CORE, discover
|
from core.modules import Module, CORE, discover
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
|
@ -391,16 +392,20 @@ def api_stacks():
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _stack_logo(d):
|
def _stack_meta(d) -> dict:
|
||||||
# 1) Voorkeur: opgeslagen metadata bij installatie
|
|
||||||
mf = d / ".serverup.json"
|
mf = d / ".serverup.json"
|
||||||
if mf.exists():
|
if mf.exists():
|
||||||
try:
|
try:
|
||||||
m = json.loads(mf.read_text("utf-8"))
|
return json.loads(mf.read_text("utf-8"))
|
||||||
if m.get("logo_url") or m.get("icon"):
|
|
||||||
return m.get("logo_url", ""), m.get("icon", "")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _stack_logo(d):
|
||||||
|
# 1) Voorkeur: opgeslagen metadata bij installatie
|
||||||
|
m = _stack_meta(d)
|
||||||
|
if m.get("logo_url") or m.get("icon"):
|
||||||
|
return m.get("logo_url", ""), m.get("icon", "")
|
||||||
# 2) Fallback: match op naam met de store (ook voor oudere installaties)
|
# 2) Fallback: match op naam met de store (ook voor oudere installaties)
|
||||||
if d.name in icon_map:
|
if d.name in icon_map:
|
||||||
h = icon_map[d.name]; return h["logo_url"], h["icon"]
|
h = icon_map[d.name]; return h["logo_url"], h["icon"]
|
||||||
|
|
@ -417,11 +422,13 @@ def api_stacks():
|
||||||
continue
|
continue
|
||||||
ct = docker.compose_ps(d, name=d.name)
|
ct = docker.compose_ps(d, name=d.name)
|
||||||
logo_url, icon = _stack_logo(d)
|
logo_url, icon = _stack_logo(d)
|
||||||
|
meta = _stack_meta(d)
|
||||||
out.append({
|
out.append({
|
||||||
"name": d.name, "path": str(d), "has_compose": True,
|
"name": d.name, "path": str(d), "has_compose": True,
|
||||||
"running": any(c["running"] for c in ct),
|
"running": any(c["running"] for c in ct),
|
||||||
"containers": ct,
|
"containers": ct,
|
||||||
"logo_url": logo_url, "icon": icon,
|
"logo_url": logo_url, "icon": icon,
|
||||||
|
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
|
||||||
})
|
})
|
||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
@ -536,10 +543,66 @@ def api_stack_compose_put(name):
|
||||||
lib, d = _stack_dir(name)
|
lib, d = _stack_dir(name)
|
||||||
if not d:
|
if not d:
|
||||||
return jsonify(ok=False, msg="ongeldige naam"), 400
|
return jsonify(ok=False, msg="ongeldige naam"), 400
|
||||||
docker.write_compose(d, (request.json or {}).get("content", ""))
|
content = (request.json or {}).get("content", "")
|
||||||
|
# Eerst valideren, dan pas schrijven: een typefout maakt anders een
|
||||||
|
# draaiende stack onstartbaar zonder dat je het merkt.
|
||||||
|
ok, err = docker.validate_compose_text(content, d)
|
||||||
|
if not ok:
|
||||||
|
return jsonify(ok=False, msg=f"Compose is ongeldig:\n{err}"), 400
|
||||||
|
docker.write_compose(d, content)
|
||||||
|
audit.log("stacks", "compose_edit", "ok", ref=name, ip=request.remote_addr)
|
||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Containers binnen een stack ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _stack_containers(name: str) -> list[str]:
|
||||||
|
"""Namen van de containers die bij deze stack horen."""
|
||||||
|
lib, d = _stack_dir(name)
|
||||||
|
if not d or not d.exists():
|
||||||
|
return []
|
||||||
|
return [c["name"] for c in docker.compose_ps(d, name=name) if c.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stacks/<name>/containers")
|
||||||
|
def api_stack_containers(name):
|
||||||
|
lib, d = _stack_dir(name)
|
||||||
|
if not d:
|
||||||
|
return jsonify(ok=False, msg="ongeldige naam"), 400
|
||||||
|
if not d.exists():
|
||||||
|
return jsonify(ok=False, msg="niet gevonden"), 404
|
||||||
|
containers = docker.compose_ps(d, name=name)
|
||||||
|
live = docker.stats([c["name"] for c in containers if c.get("running")])
|
||||||
|
for c in containers:
|
||||||
|
c["stats"] = live.get(c["name"], {})
|
||||||
|
return jsonify(ok=True, containers=containers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stacks/<name>/containers/<container>/<action>", methods=["POST"])
|
||||||
|
def api_container_action(name, container, action):
|
||||||
|
"""Start/stop/herstart één container binnen een stack.
|
||||||
|
|
||||||
|
De containernaam wordt gecontroleerd tegen de containers die bij déze stack
|
||||||
|
horen; zo kan deze route nooit een willekeurige container op de host raken.
|
||||||
|
"""
|
||||||
|
if action not in ("start", "stop", "restart"):
|
||||||
|
return jsonify(ok=False, msg="onbekende actie"), 400
|
||||||
|
if container not in _stack_containers(name):
|
||||||
|
return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404
|
||||||
|
ok, msg = docker.container_action(container, action)
|
||||||
|
audit.log("containers", action, "ok" if ok else "error", ref=container,
|
||||||
|
ip=request.remote_addr)
|
||||||
|
return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stacks/<name>/containers/<container>/logs")
|
||||||
|
def api_container_logs(name, container):
|
||||||
|
if container not in _stack_containers(name):
|
||||||
|
return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404
|
||||||
|
tail = min(max(int(request.args.get("tail", 200)), 1), 2000)
|
||||||
|
return jsonify(ok=True, logs=docker.container_logs(container, tail))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stacks/<name>/logs")
|
@app.route("/api/stacks/<name>/logs")
|
||||||
def api_stack_logs(name):
|
def api_stack_logs(name):
|
||||||
lib, d = _stack_dir(name)
|
lib, d = _stack_dir(name)
|
||||||
|
|
@ -664,6 +727,47 @@ def _find_stack_src(stack: str, rid: str) -> Path | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET_HINTS = ("token", "password", "passwd", "secret", "apikey", "api_key",
|
||||||
|
"_key", "salt", "pepper")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret_field(f: dict) -> bool:
|
||||||
|
haystack = f"{f.get('name', '')} {f.get('title', '')}".lower()
|
||||||
|
return any(h in haystack for h in _SECRET_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_fields(fields: list[dict]) -> list[dict]:
|
||||||
|
"""Verrijk de installatievelden met een vrije poort en een secret-vlag.
|
||||||
|
|
||||||
|
- poortvelden krijgen een default die nog niet in gebruik is, zodat een
|
||||||
|
installatie niet strandt op 'port is already allocated';
|
||||||
|
- geheime velden worden gemarkeerd zodat de UI een genereerknop toont.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bezet = docker.used_ports()
|
||||||
|
except Exception:
|
||||||
|
bezet = set()
|
||||||
|
uitgedeeld: set[int] = set()
|
||||||
|
for f in fields:
|
||||||
|
if _is_secret_field(f):
|
||||||
|
f["secret"] = True
|
||||||
|
naam = (f.get("name") or "").lower()
|
||||||
|
if f.get("type") == "int" and "port" in naam:
|
||||||
|
try:
|
||||||
|
gewenst = int(f.get("default") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
gewenst = 0
|
||||||
|
kandidaat = gewenst if gewenst > 0 else 8100
|
||||||
|
while kandidaat in bezet or kandidaat in uitgedeeld:
|
||||||
|
kandidaat += 1
|
||||||
|
uitgedeeld.add(kandidaat)
|
||||||
|
f["suggested"] = kandidaat
|
||||||
|
if kandidaat != gewenst:
|
||||||
|
f["port_taken"] = gewenst
|
||||||
|
f["default"] = kandidaat
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/store/preview", methods=["POST"])
|
@app.route("/api/store/preview", methods=["POST"])
|
||||||
def api_store_preview():
|
def api_store_preview():
|
||||||
"""Preview een stack: lees compose + .env en extract configureerbare velden.
|
"""Preview een stack: lees compose + .env en extract configureerbare velden.
|
||||||
|
|
@ -681,7 +785,7 @@ def api_store_preview():
|
||||||
# ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
|
# ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
|
||||||
if boilerplates.is_boilerplate(src):
|
if boilerplates.is_boilerplate(src):
|
||||||
meta = boilerplates.metadata(src)
|
meta = boilerplates.metadata(src)
|
||||||
flds = boilerplates.fields(src)
|
flds = _annotate_fields(boilerplates.fields(src))
|
||||||
# Render een preview met defaults, zodat de gebruiker ziet wat er komt
|
# Render een preview met defaults, zodat de gebruiker ziet wat er komt
|
||||||
try:
|
try:
|
||||||
ctx = boilerplates.build_context(src, {})
|
ctx = boilerplates.build_context(src, {})
|
||||||
|
|
@ -724,16 +828,25 @@ def api_store_preview():
|
||||||
k, v = k.strip(), v.strip()
|
k, v = k.strip(), v.strip()
|
||||||
is_port = "PORT" in k.upper()
|
is_port = "PORT" in k.upper()
|
||||||
is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION")
|
is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION")
|
||||||
result["env_vars"].append({
|
entry = {
|
||||||
"key": k, "value": v,
|
"key": k, "value": v,
|
||||||
"is_port": is_port, "is_tag": is_tag,
|
"is_port": is_port, "is_tag": is_tag,
|
||||||
|
"secret": _is_secret_field({"name": k, "title": k}),
|
||||||
"desc": "Poort" if is_port else "Image tag" if is_tag else "",
|
"desc": "Poort" if is_port else "Image tag" if is_tag else "",
|
||||||
})
|
}
|
||||||
if is_port:
|
if is_port:
|
||||||
try:
|
try:
|
||||||
result["ports"].append({"key": k, "value": int(v)})
|
poort = int(v)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
result["ports"].append({"key": k, "value": v})
|
result["ports"].append({"key": k, "value": v})
|
||||||
|
else:
|
||||||
|
vrij = docker.next_free_port(poort)
|
||||||
|
entry["suggested"] = vrij
|
||||||
|
if vrij != poort:
|
||||||
|
entry["port_taken"] = poort
|
||||||
|
entry["value"] = str(vrij)
|
||||||
|
result["ports"].append({"key": k, "value": vrij})
|
||||||
|
result["env_vars"].append(entry)
|
||||||
|
|
||||||
# Parse compose voor images
|
# Parse compose voor images
|
||||||
cf = docker.find_compose(src)
|
cf = docker.find_compose(src)
|
||||||
|
|
@ -787,8 +900,24 @@ def api_store_install():
|
||||||
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
|
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
|
||||||
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
|
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
|
||||||
bp_values = d.get("values", {}) # boilerplate variable values
|
bp_values = d.get("values", {}) # boilerplate variable values
|
||||||
|
net_name = (d.get("network") or "").strip() # leeg = poorten op de host
|
||||||
|
net_ip = (d.get("ip") or "").strip()
|
||||||
if not stack:
|
if not stack:
|
||||||
return jsonify(ok=False, msg="stack vereist")
|
return jsonify(ok=False, msg="stack vereist")
|
||||||
|
|
||||||
|
net = None
|
||||||
|
if net_name:
|
||||||
|
net = _network_by_name(net_name)
|
||||||
|
if not net:
|
||||||
|
return jsonify(ok=False, msg=f"Netwerk '{net_name}' is niet geconfigureerd"), 400
|
||||||
|
if not net_ip:
|
||||||
|
net_ip = networks.next_free_ip(net, _reserved_ips())
|
||||||
|
if not net_ip:
|
||||||
|
return jsonify(ok=False, msg="Geen vrij IP-adres beschikbaar in dit netwerk"), 400
|
||||||
|
if not networks.ip_in_network(net_ip, net):
|
||||||
|
return jsonify(ok=False, msg=f"{net_ip} ligt buiten het subnet van '{net_name}'"), 400
|
||||||
|
if net_ip in _reserved_ips() or net_ip in networks.used_ips(net_name):
|
||||||
|
return jsonify(ok=False, msg=f"{net_ip} is al in gebruik"), 409
|
||||||
# De instantienaam bepaalt de doelmap én de compose-projectnaam. Zonder
|
# De instantienaam bepaalt de doelmap én de compose-projectnaam. Zonder
|
||||||
# controle schrijft `"instance": "../../etc"` buiten de library.
|
# controle schrijft `"instance": "../../etc"` buiten de library.
|
||||||
if not cfg.safe_name(inst):
|
if not cfg.safe_name(inst):
|
||||||
|
|
@ -897,6 +1026,30 @@ def api_store_install():
|
||||||
cf.write_text(new_content)
|
cf.write_text(new_content)
|
||||||
jobs.log(qq, "ok", "Image tags aangepast")
|
jobs.log(qq, "ok", "Image tags aangepast")
|
||||||
|
|
||||||
|
# Eigen IP-adres in plaats van poorten op de host
|
||||||
|
if net:
|
||||||
|
cf = docker.find_compose(dest)
|
||||||
|
if not cf:
|
||||||
|
jobs.log(qq, "error", "Geen compose-bestand om aan te passen")
|
||||||
|
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||||
|
try:
|
||||||
|
nieuw = compose_transform.to_dedicated_ip(
|
||||||
|
cf.read_text(encoding="utf-8"), net_name, net_ip)
|
||||||
|
except compose_transform.TransformError as e:
|
||||||
|
jobs.log(qq, "error", f"Netwerkomzetting mislukt: {e}")
|
||||||
|
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||||
|
cf.write_text(nieuw, encoding="utf-8")
|
||||||
|
jobs.log(qq, "ok", f"Eigen IP-adres: {net_ip} op netwerk '{net_name}'")
|
||||||
|
jobs.log(qq, "dim", "Poortmappings verwijderd — de app draait op "
|
||||||
|
"zijn eigen poort op dit adres.")
|
||||||
|
|
||||||
|
# Valideer het resultaat vóór we proberen te starten, zodat een
|
||||||
|
# fout in het template een duidelijke melding geeft.
|
||||||
|
ok_cfg, err_cfg = docker.validate_compose(dest)
|
||||||
|
if not ok_cfg:
|
||||||
|
jobs.log(qq, "error", f"Compose is ongeldig:\n{err_cfg}")
|
||||||
|
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||||
|
|
||||||
# Data dir
|
# Data dir
|
||||||
try:
|
try:
|
||||||
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True)
|
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -915,6 +1068,10 @@ def api_store_install():
|
||||||
"name": meta.get("name", stack),
|
"name": meta.get("name", stack),
|
||||||
"logo_url": logo,
|
"logo_url": logo,
|
||||||
"icon": meta.get("icon", ""),
|
"icon": meta.get("icon", ""),
|
||||||
|
# Reserveert het adres: zolang deze stack bestaat krijgt
|
||||||
|
# geen andere installatie hetzelfde IP voorgesteld.
|
||||||
|
"network": net_name,
|
||||||
|
"ip": net_ip if net else "",
|
||||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -939,6 +1096,97 @@ def api_store_install():
|
||||||
return jsonify(ok=True, job_id=jid)
|
return jsonify(ok=True, job_id=jid)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _network_by_name(name: str) -> dict | None:
|
||||||
|
for n in cfg.load().get("NETWORKS", []) or []:
|
||||||
|
if isinstance(n, dict) and n.get("name") == name:
|
||||||
|
return n
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reserved_ips(exclude_stack: str = "") -> set[str]:
|
||||||
|
"""IP's die Server Up bij installatie aan een stack heeft toegekend.
|
||||||
|
|
||||||
|
Staat los van used_ips(): een gestopte stack houdt zijn adres, zodat een
|
||||||
|
nieuwe installatie het niet afpakt.
|
||||||
|
"""
|
||||||
|
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||||
|
ips: set[str] = set()
|
||||||
|
if not lib.exists():
|
||||||
|
return ips
|
||||||
|
for d in lib.iterdir():
|
||||||
|
if not d.is_dir() or d.name == exclude_stack:
|
||||||
|
continue
|
||||||
|
mf = d / ".serverup.json"
|
||||||
|
if not mf.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = json.loads(mf.read_text("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if meta.get("ip"):
|
||||||
|
ips.add(meta["ip"])
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/networks")
|
||||||
|
def api_networks():
|
||||||
|
defined = cfg.load().get("NETWORKS", []) or []
|
||||||
|
live = {n["name"]: n for n in networks.list_docker_networks()}
|
||||||
|
out = []
|
||||||
|
for n in defined:
|
||||||
|
if not isinstance(n, dict):
|
||||||
|
continue
|
||||||
|
naam = n.get("name", "")
|
||||||
|
entry = {**n, "created": naam in live,
|
||||||
|
"used_ips": sorted(networks.used_ips(naam)) if naam in live else []}
|
||||||
|
entry["next_ip"] = networks.next_free_ip(n, _reserved_ips())
|
||||||
|
out.append(entry)
|
||||||
|
return jsonify(networks=out, interfaces=networks.host_interfaces(),
|
||||||
|
drivers=list(networks.DRIVERS))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/networks", methods=["POST"])
|
||||||
|
def api_networks_add():
|
||||||
|
d = request.json or {}
|
||||||
|
net = {"name": (d.get("name") or "").strip(),
|
||||||
|
"driver": d.get("driver") or "macvlan",
|
||||||
|
"parent": (d.get("parent") or "").strip(),
|
||||||
|
"subnet": (d.get("subnet") or "").strip(),
|
||||||
|
"gateway": (d.get("gateway") or "").strip(),
|
||||||
|
"ip_range": (d.get("ip_range") or "").strip()}
|
||||||
|
probleem = networks.validate(net)
|
||||||
|
if probleem:
|
||||||
|
return jsonify(ok=False, msg=probleem), 400
|
||||||
|
if _network_by_name(net["name"]):
|
||||||
|
return jsonify(ok=False, msg="Er bestaat al een netwerk met deze naam"), 409
|
||||||
|
ok, msg = networks.create(net)
|
||||||
|
if not ok:
|
||||||
|
return jsonify(ok=False, msg=msg), 400
|
||||||
|
cfg.patch({"NETWORKS": list(cfg.load().get("NETWORKS", []) or []) + [net]})
|
||||||
|
audit.log("networks", "add", "ok", ref=net["name"], ip=request.remote_addr)
|
||||||
|
return jsonify(ok=True, msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/networks/<name>", methods=["DELETE"])
|
||||||
|
def api_networks_del(name):
|
||||||
|
net = _network_by_name(name)
|
||||||
|
if not net:
|
||||||
|
return jsonify(ok=False, msg="Netwerk niet gevonden"), 404
|
||||||
|
in_gebruik = sorted(networks.used_ips(name))
|
||||||
|
if in_gebruik:
|
||||||
|
return jsonify(ok=False,
|
||||||
|
msg=f"Nog {len(in_gebruik)} container(s) op dit netwerk — "
|
||||||
|
"stop die stacks eerst."), 409
|
||||||
|
ok, msg = networks.remove(name)
|
||||||
|
cfg.patch({"NETWORKS": [n for n in cfg.load().get("NETWORKS", []) or []
|
||||||
|
if n.get("name") != name]})
|
||||||
|
audit.log("networks", "delete", "ok" if ok else "error", ref=name,
|
||||||
|
ip=request.remote_addr)
|
||||||
|
return jsonify(ok=True, msg=msg if ok else f"Uit config verwijderd ({msg})")
|
||||||
|
|
||||||
|
|
||||||
# ── Module Store ──────────────────────────────────────────────────────────────
|
# ── Module Store ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/modules")
|
@app.route("/api/modules")
|
||||||
|
|
|
||||||
87
server-up/core/compose_transform.py
Normal file
87
server-up/core/compose_transform.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Compose-transformatie: gepubliceerde poorten → een eigen IP-adres.
|
||||||
|
|
||||||
|
Wordt toegepast op het al gerenderde compose-bestand. Daardoor werkt het voor
|
||||||
|
zowel het boilerplate-formaat als het klassieke Server Up-formaat, en hoeven de
|
||||||
|
templates in apps/ niet aangepast te worden.
|
||||||
|
|
||||||
|
Voorbeeld:
|
||||||
|
|
||||||
|
services: services:
|
||||||
|
web: web:
|
||||||
|
image: nginx image: nginx
|
||||||
|
ports: → networks:
|
||||||
|
- "8080:80" lan:
|
||||||
|
ipv4_address: 192.168.1.240
|
||||||
|
networks:
|
||||||
|
lan:
|
||||||
|
external: true
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class TransformError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def to_dedicated_ip(compose_text: str, network: str, ip: str,
|
||||||
|
only_service: str | None = None) -> str:
|
||||||
|
"""Geef het compose-bestand terug met poortmappings vervangen door een IP.
|
||||||
|
|
||||||
|
Het IP wordt toegekend aan één service: `only_service` als die is opgegeven,
|
||||||
|
anders de eerste service die poorten publiceert (en anders de eerste service
|
||||||
|
überhaupt). Overige services komen zonder vast adres in hetzelfde netwerk,
|
||||||
|
zodat ze elkaar op servicenaam kunnen blijven vinden.
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
raise TransformError("compose bevat geen mapping op het hoogste niveau")
|
||||||
|
|
||||||
|
services = doc.get("services")
|
||||||
|
if not isinstance(services, dict) or not services:
|
||||||
|
raise TransformError("compose bevat geen services")
|
||||||
|
|
||||||
|
doel = only_service
|
||||||
|
if doel is None:
|
||||||
|
doel = next((naam for naam, svc in services.items()
|
||||||
|
if isinstance(svc, dict) and svc.get("ports")), None)
|
||||||
|
if doel is None:
|
||||||
|
doel = next(iter(services))
|
||||||
|
if doel not in services:
|
||||||
|
raise TransformError(f"service '{doel}' bestaat niet in dit compose-bestand")
|
||||||
|
|
||||||
|
for naam, svc in services.items():
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
# Poorten publiceren heeft geen zin meer — de container is direct
|
||||||
|
# bereikbaar op zijn eigen adres.
|
||||||
|
svc.pop("ports", None)
|
||||||
|
if naam == doel:
|
||||||
|
svc["networks"] = {network: {"ipv4_address": ip}}
|
||||||
|
else:
|
||||||
|
svc["networks"] = [network]
|
||||||
|
|
||||||
|
# Het netwerk wordt door Server Up beheerd, niet door deze compose-stack.
|
||||||
|
doc["networks"] = {network: {"external": True}}
|
||||||
|
|
||||||
|
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||||
|
allow_unicode=True, width=120)
|
||||||
|
|
||||||
|
|
||||||
|
def published_ports(compose_text: str) -> list[str]:
|
||||||
|
"""Alle host-poorten die dit compose-bestand publiceert (voor de UI)."""
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(compose_text) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for svc in (doc.get("services") or {}).values():
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
for p in svc.get("ports") or []:
|
||||||
|
out.append(str(p))
|
||||||
|
return out
|
||||||
|
|
@ -137,6 +137,41 @@ def restart_container(name: str) -> tuple[bool, str]:
|
||||||
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def container_action(name: str, action: str) -> tuple[bool, str]:
|
||||||
|
"""Start, stop of herstart één container.
|
||||||
|
|
||||||
|
De aanroeper is verantwoordelijk voor het controleren dát deze container bij
|
||||||
|
een door Server Up beheerde stack hoort (zie app.api_container_action).
|
||||||
|
"""
|
||||||
|
if action not in ("start", "stop", "restart"):
|
||||||
|
return False, "onbekende actie"
|
||||||
|
r = _run([_bin(), action, name], timeout=60)
|
||||||
|
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def container_logs(name: str, tail=200) -> str:
|
||||||
|
r = _run([_bin(), "logs", "--tail", str(int(tail)), name], timeout=15)
|
||||||
|
return r.stdout or r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def stats(names: list[str]) -> dict[str, dict]:
|
||||||
|
"""Momentopname van CPU/geheugen per container: {naam: {cpu, mem, ...}}."""
|
||||||
|
if not names:
|
||||||
|
return {}
|
||||||
|
fmt = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||||
|
r = _run([_bin(), "stats", "--no-stream", "--format", fmt] + list(names), timeout=25)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return {}
|
||||||
|
out = {}
|
||||||
|
for line in r.stdout.strip().splitlines():
|
||||||
|
p = line.split("\t")
|
||||||
|
if len(p) >= 4:
|
||||||
|
out[p[0]] = {"cpu": p[1], "mem": p[2], "mem_pct": p[3],
|
||||||
|
"net": p[4] if len(p) > 4 else "",
|
||||||
|
"block": p[5] if len(p) > 5 else ""}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Compose helpers ───────────────────────────────────────────────────────────
|
# ── Compose helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def find_compose(d: Path) -> Path | None:
|
def find_compose(d: Path) -> Path | None:
|
||||||
|
|
@ -244,6 +279,52 @@ def write_compose(d: Path, content: str):
|
||||||
f.write_text(content)
|
f.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose(d: Path) -> tuple[bool, str]:
|
||||||
|
"""Controleer een compose-bestand met `docker compose config -q`.
|
||||||
|
|
||||||
|
Zo blijkt een typefout meteen bij het opslaan, in plaats van pas bij de
|
||||||
|
volgende start — als de stack al stuk is.
|
||||||
|
"""
|
||||||
|
f = find_compose(d)
|
||||||
|
if not f:
|
||||||
|
return False, "geen compose-bestand gevonden"
|
||||||
|
r = _run(_get_compose() + ["-f", str(f), "config", "-q"], cwd=str(d), timeout=30)
|
||||||
|
return _validation_result(r)
|
||||||
|
|
||||||
|
|
||||||
|
def _validation_result(r) -> tuple[bool, str]:
|
||||||
|
if r.returncode == 0:
|
||||||
|
return True, ""
|
||||||
|
# Is de compose-CLI zelf niet beschikbaar, dan kunnen we niets zeggen over
|
||||||
|
# het bestand. Dat mag geen reden zijn om een installatie te blokkeren.
|
||||||
|
melding = (r.stderr or r.stdout or "").strip()
|
||||||
|
if r.returncode == 127 or "niet gevonden" in melding or "not found" in melding.lower():
|
||||||
|
return True, ""
|
||||||
|
return False, melding[:800]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
|
||||||
|
"""Valideer compose-inhoud zonder het doelbestand te overschrijven.
|
||||||
|
|
||||||
|
Het tijdelijke bestand staat in dezelfde map, zodat relatieve paden en een
|
||||||
|
eventueel .env-bestand meegenomen worden zoals bij een echte start.
|
||||||
|
"""
|
||||||
|
tmp = d / ".serverup-validate.yml"
|
||||||
|
try:
|
||||||
|
tmp.write_text(content, encoding="utf-8")
|
||||||
|
r = _run(_get_compose() + ["-f", str(tmp), "config", "-q"],
|
||||||
|
cwd=str(d), timeout=30)
|
||||||
|
ok, msg = _validation_result(r)
|
||||||
|
return ok, msg.replace(str(tmp), "compose")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def used_ports() -> set[int]:
|
def used_ports() -> set[int]:
|
||||||
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
|
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
|
||||||
ports = set()
|
ports = set()
|
||||||
|
|
|
||||||
181
server-up/core/networks.py
Normal file
181
server-up/core/networks.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""Macvlan/ipvlan-netwerken — stacks een eigen IP-adres in het LAN geven.
|
||||||
|
|
||||||
|
In plaats van poorten publiceren op de host (`8222:80`) krijgt een container een
|
||||||
|
eigen adres op het fysieke netwerk. Voordelen: geen poortconflicten meer, apps
|
||||||
|
draaien op hun eigen standaardpoort, en je kunt per app firewallen op IP.
|
||||||
|
|
||||||
|
Belangrijke beperking van macvlan: de host kan zijn eigen macvlan-containers
|
||||||
|
niet bereiken zonder een extra shim-interface. Zie docs/netwerken.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from core import docker
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
|
||||||
|
DRIVERS = ("macvlan", "ipvlan")
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inspectie ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_docker_networks() -> list[dict]:
|
||||||
|
"""Alle macvlan/ipvlan-netwerken die de Docker-daemon kent."""
|
||||||
|
r = docker._run([docker._bin(), "network", "ls", "--format",
|
||||||
|
"{{.Name}}\t{{.Driver}}\t{{.Scope}}"], timeout=15)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for line in r.stdout.strip().splitlines():
|
||||||
|
p = line.split("\t")
|
||||||
|
if len(p) >= 2 and p[1] in DRIVERS:
|
||||||
|
out.append({"name": p[0], "driver": p[1],
|
||||||
|
"scope": p[2] if len(p) > 2 else ""})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def inspect(name: str) -> dict:
|
||||||
|
r = docker._run([docker._bin(), "network", "inspect", name], timeout=15)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
return data[0] if data else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def exists(name: str) -> bool:
|
||||||
|
return bool(inspect(name))
|
||||||
|
|
||||||
|
|
||||||
|
def used_ips(name: str) -> set[str]:
|
||||||
|
"""IP-adressen die binnen dit netwerk al aan een container zijn toegekend."""
|
||||||
|
info = inspect(name)
|
||||||
|
ips: set[str] = set()
|
||||||
|
for c in (info.get("Containers") or {}).values():
|
||||||
|
addr = (c.get("IPv4Address") or "").split("/")[0]
|
||||||
|
if addr:
|
||||||
|
ips.add(addr)
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def host_interfaces() -> list[str]:
|
||||||
|
"""Netwerkinterfaces van de host, als suggestie voor `parent`.
|
||||||
|
|
||||||
|
De container draait met het netwerk van de host niet gedeeld, maar /sys is
|
||||||
|
wél zichtbaar wanneer de docker-socket gemount is; lukt het lezen niet, dan
|
||||||
|
krijgt de gebruiker gewoon een leeg lijstje en vult hij het zelf in.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
# /sys/class/net bevat naast interfaces ook losse bestanden zoals
|
||||||
|
# 'bonding_masters'; echte interfaces zijn symlinks naar een device.
|
||||||
|
namen = sorted(p.name for p in Path("/sys/class/net").iterdir() if p.is_dir())
|
||||||
|
return [n for n in namen
|
||||||
|
if n != "lo" and not n.startswith(("docker", "br-", "veth"))]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── IP-toewijzing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def next_free_ip(net: dict, extra_used: set[str] | None = None) -> str:
|
||||||
|
"""Eerste vrije adres binnen ip_range (of subnet), gateway uitgezonderd.
|
||||||
|
|
||||||
|
Zelfde patroon als docker.next_free_port(): een voorstel, geen reservering.
|
||||||
|
"""
|
||||||
|
subnet = net.get("ip_range") or net.get("subnet") or ""
|
||||||
|
if not subnet:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(subnet, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
bezet = set(used_ips(net.get("name", "")) if net.get("name") else set())
|
||||||
|
bezet |= set(extra_used or set())
|
||||||
|
gateway = (net.get("gateway") or "").strip()
|
||||||
|
if gateway:
|
||||||
|
bezet.add(gateway)
|
||||||
|
for host in network.hosts():
|
||||||
|
if str(host) not in bezet:
|
||||||
|
return str(host)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def ip_in_network(ip: str, net: dict) -> bool:
|
||||||
|
subnet = net.get("subnet") or ""
|
||||||
|
if not subnet:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(ip) in ipaddress.ip_network(subnet, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aanmaken / verwijderen ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def validate(net: dict) -> str:
|
||||||
|
"""Retourneer een foutmelding, of "" als de definitie klopt."""
|
||||||
|
if not _NAME_RE.match(net.get("name") or ""):
|
||||||
|
return "Ongeldige netwerknaam."
|
||||||
|
if net.get("driver") not in DRIVERS:
|
||||||
|
return f"Driver moet {' of '.join(DRIVERS)} zijn."
|
||||||
|
if not _NAME_RE.match(net.get("parent") or ""):
|
||||||
|
return "Geef een geldige host-interface op (bv. eth0)."
|
||||||
|
try:
|
||||||
|
subnet = ipaddress.ip_network(net.get("subnet") or "", strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return "Ongeldig subnet (bv. 192.168.1.0/24)."
|
||||||
|
gateway = (net.get("gateway") or "").strip()
|
||||||
|
if gateway:
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(gateway) not in subnet:
|
||||||
|
return "De gateway ligt buiten het subnet."
|
||||||
|
except ValueError:
|
||||||
|
return "Ongeldig gateway-adres."
|
||||||
|
rng = (net.get("ip_range") or "").strip()
|
||||||
|
if rng:
|
||||||
|
try:
|
||||||
|
if not ipaddress.ip_network(rng, strict=False).subnet_of(subnet):
|
||||||
|
return "De IP-range ligt buiten het subnet."
|
||||||
|
except ValueError:
|
||||||
|
return "Ongeldige IP-range (bv. 192.168.1.240/28)."
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def create(net: dict) -> tuple[bool, str]:
|
||||||
|
"""Maak het netwerk aan bij de Docker-daemon (idempotent)."""
|
||||||
|
probleem = validate(net)
|
||||||
|
if probleem:
|
||||||
|
return False, probleem
|
||||||
|
if exists(net["name"]):
|
||||||
|
return True, "Netwerk bestaat al."
|
||||||
|
cmd = [docker._bin(), "network", "create",
|
||||||
|
"-d", net["driver"],
|
||||||
|
"--subnet", net["subnet"],
|
||||||
|
"-o", f"parent={net['parent']}"]
|
||||||
|
if net.get("gateway"):
|
||||||
|
cmd += ["--gateway", net["gateway"]]
|
||||||
|
if net.get("ip_range"):
|
||||||
|
cmd += ["--ip-range", net["ip_range"]]
|
||||||
|
cmd.append(net["name"])
|
||||||
|
r = docker._run(cmd, timeout=30)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False, (r.stderr or r.stdout).strip()[:400]
|
||||||
|
return True, "Netwerk aangemaakt."
|
||||||
|
|
||||||
|
|
||||||
|
def remove(name: str) -> tuple[bool, str]:
|
||||||
|
if not _NAME_RE.match(name or ""):
|
||||||
|
return False, "Ongeldige netwerknaam."
|
||||||
|
r = docker._run([docker._bin(), "network", "rm", name], timeout=30)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False, (r.stderr or r.stdout).strip()[:400]
|
||||||
|
return True, "Netwerk verwijderd."
|
||||||
|
|
@ -162,6 +162,7 @@ tailwind.config = {
|
||||||
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? '–'"></span> images</div>
|
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? '–'"></span> images</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
|
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
|
||||||
|
<button @click="logout()" :title="'Uitloggen (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -254,13 +255,53 @@ tailwind.config = {
|
||||||
<template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template>
|
<template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template>
|
||||||
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="!s.has_compose ? 'bg-wr' : s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
|
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="!s.has_compose ? 'bg-wr' : s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
|
||||||
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
|
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
|
||||||
|
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
|
||||||
|
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
|
||||||
|
:title="'Eigen IP op netwerk ' + s.network" x-text="s.ip"></span>
|
||||||
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
|
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex flex-col gap-3 flex-1">
|
<div class="p-4 flex flex-col gap-3 flex-1">
|
||||||
<div class="flex flex-wrap gap-1.5" x-show="s.containers && s.containers.length">
|
<!-- Containerchips; klik klapt het beheerpaneel per container uit -->
|
||||||
|
<div class="flex flex-wrap gap-1.5 items-center cursor-pointer"
|
||||||
|
x-show="s.containers && s.containers.length"
|
||||||
|
@click="toggleContainers(s.name)">
|
||||||
<template x-for="c in s.containers || []" :key="c.name">
|
<template x-for="c in s.containers || []" :key="c.name">
|
||||||
<span class="text-[11px] font-mono px-2 py-0.5 rounded-md border" :class="c.running ? 'border-ok/30 text-ok bg-ok/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" x-text="c.service || c.name"></span>
|
<span class="text-[11px] font-mono px-2 py-0.5 rounded-md border" :class="c.running ? 'border-ok/30 text-ok bg-ok/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" x-text="c.service || c.name"></span>
|
||||||
</template>
|
</template>
|
||||||
|
<span class="mdi text-t2 dark:text-t2-dark text-base ml-auto"
|
||||||
|
:class="cpanel[s.name] && cpanel[s.name].open ? 'mdi-chevron-up' : 'mdi-chevron-down'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="cpanel[s.name] && cpanel[s.name].open" x-cloak
|
||||||
|
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
|
||||||
|
<template x-if="cpanel[s.name] && cpanel[s.name].loading">
|
||||||
|
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
|
||||||
|
<span class="mdi mdi-loading mdi-spin"></span> Laden…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
|
||||||
|
<div class="px-3 py-2.5 bg-s2/40 dark:bg-s2-dark/40">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full shrink-0" :class="c.running ? 'bg-ok' : 'bg-dm dark:bg-dm-dark'"></span>
|
||||||
|
<span class="text-xs font-semibold text-wh dark:text-wh-dark truncate flex-1" x-text="c.service || c.name"></span>
|
||||||
|
<button class="btn-sm !h-7 !px-2" :title="c.running ? t('stop') : t('start')"
|
||||||
|
@click.stop="containerAction(s.name, c.name, c.running ? 'stop' : 'start')">
|
||||||
|
<span class="mdi" :class="c.running ? 'mdi-stop' : 'mdi-play'"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm !h-7 !px-2" :title="t('restart')"
|
||||||
|
@click.stop="containerAction(s.name, c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
|
||||||
|
<button class="btn-sm !h-7 !px-2" :title="t('logs')"
|
||||||
|
@click.stop="openContainerLogs(s.name, c.name)"><span class="mdi mdi-text-box-outline"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[10px] font-mono text-t2 dark:text-t2-dark"
|
||||||
|
x-show="c.stats && c.stats.cpu">
|
||||||
|
<span>CPU <span class="text-tx dark:text-tx-dark" x-text="c.stats.cpu"></span></span>
|
||||||
|
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span>
|
||||||
|
<span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose">
|
<div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose">
|
||||||
<button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')">
|
<button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')">
|
||||||
|
|
@ -422,6 +463,73 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Beveiliging: accounts + wachtwoord -->
|
||||||
|
<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-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
|
||||||
|
<span class="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span>
|
||||||
|
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="form-label">Gebruikers</div>
|
||||||
|
<div class="space-y-1.5 mb-3">
|
||||||
|
<template x-for="u in users" :key="u">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="mdi mdi-account-outline text-t2 dark:text-t2-dark"></span>
|
||||||
|
<span class="text-tx dark:text-tx-dark flex-1" x-text="u"></span>
|
||||||
|
<button class="btn-sm !h-7 !px-2 btn-danger" x-show="users.length > 1"
|
||||||
|
@click="delUser(u)"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam">
|
||||||
|
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)">
|
||||||
|
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="form-label">Eigen wachtwoord wijzigen</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord">
|
||||||
|
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord">
|
||||||
|
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen">
|
||||||
|
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||||
|
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
|
||||||
|
<span class="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span>
|
||||||
|
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<p class="text-xs text-t2 dark:text-t2-dark mb-3">
|
||||||
|
Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN
|
||||||
|
in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app
|
||||||
|
firewallen. De Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="n in netList" :key="n.name">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
|
||||||
|
<span class="w-2 h-2 rounded-full shrink-0" :class="n.created ? 'bg-ok' : 'bg-wr'"></span>
|
||||||
|
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
|
||||||
|
<span class="badge badge-dim" x-text="n.driver"></span>
|
||||||
|
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
|
||||||
|
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip">volgend vrij: <span class="font-mono" x-text="n.next_ip"></span></span>
|
||||||
|
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark">Nog geen netwerken.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Updates -->
|
<!-- Updates -->
|
||||||
<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">
|
||||||
|
|
@ -577,6 +685,40 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Netwerk toevoegen -->
|
||||||
|
<div x-show="modal==='add-network'" class="modal max-w-md">
|
||||||
|
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> Netwerk toevoegen</div>
|
||||||
|
<div class="modal-body space-y-3">
|
||||||
|
<div><label class="form-label">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div><label class="form-label">Driver</label>
|
||||||
|
<select class="form-input" x-model="newNet.driver">
|
||||||
|
<option value="macvlan">macvlan</option>
|
||||||
|
<option value="ipvlan">ipvlan</option>
|
||||||
|
</select></div>
|
||||||
|
<div><label class="form-label">Host-interface</label>
|
||||||
|
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
|
||||||
|
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div><label class="form-label">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
|
||||||
|
<div><label class="form-label">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">IP-range (optioneel)</label>
|
||||||
|
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
|
||||||
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||||
|
Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
|
||||||
|
hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||||
|
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> Aanmaken</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Install -->
|
<!-- Install -->
|
||||||
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
|
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
|
|
@ -591,6 +733,32 @@ tailwind.config = {
|
||||||
<input class="form-input" x-model="install.instance">
|
<input class="form-input" x-model="install.instance">
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Netwerk</label>
|
||||||
|
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||||
|
<option value="">Poorten op de host (standaard)</option>
|
||||||
|
<template x-for="n in netList" :key="n.name">
|
||||||
|
<option :value="n.name" x-text="n.name + ' — eigen IP (' + n.subnet + ')'"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<template x-if="install.network">
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">IP-adres</label>
|
||||||
|
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
|
||||||
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||||
|
De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app
|
||||||
|
is bereikbaar op zijn eigen poort. Let op: de Docker-host zelf kan een
|
||||||
|
macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="!install.network && netList.length === 0"
|
||||||
|
class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
||||||
|
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
|
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
|
||||||
|
|
||||||
<!-- Boilerplate fields -->
|
<!-- Boilerplate fields -->
|
||||||
|
|
@ -618,7 +786,18 @@ tailwind.config = {
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
|
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
<template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||||
<template x-if="f.type === 'str'"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
<template x-if="f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||||
|
<!-- Geheim veld: genereerknop, zodat niemand 'admin123' invult -->
|
||||||
|
<template x-if="f.type === 'str' && f.secret">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
|
||||||
|
<button type="button" class="btn-sm shrink-0" title="Genereer een willekeurige waarde"
|
||||||
|
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1">
|
||||||
|
Poort <span x-text="f.port_taken"></span> is al in gebruik — <span x-text="f.suggested"></span> voorgesteld.
|
||||||
|
</p>
|
||||||
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
|
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -847,6 +1026,13 @@ function app() {
|
||||||
users: [], newUser: { username: '', password: '' },
|
users: [], newUser: { username: '', password: '' },
|
||||||
pwForm: { current: '', new: '', new2: '' },
|
pwForm: { current: '', new: '', new2: '' },
|
||||||
|
|
||||||
|
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
|
||||||
|
cpanel: {},
|
||||||
|
|
||||||
|
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
|
||||||
|
netList: [], netIfaces: [],
|
||||||
|
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
|
||||||
|
|
||||||
docker: { running: 0, containers: 0, images: 0 },
|
docker: { running: 0, containers: 0, images: 0 },
|
||||||
stacks: [],
|
stacks: [],
|
||||||
stackQuery: '',
|
stackQuery: '',
|
||||||
|
|
@ -1136,6 +1322,69 @@ function app() {
|
||||||
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
|
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
|
||||||
else this.toast(d.msg || 'Fout bij opslaan', 'er');
|
else this.toast(d.msg || 'Fout bij opslaan', 'er');
|
||||||
},
|
},
|
||||||
|
// ── Netwerken ────────────────────────────────────────────────────────────
|
||||||
|
async loadNetworks() {
|
||||||
|
const d = await this.rpc('/api/networks');
|
||||||
|
this.netList = d.networks || [];
|
||||||
|
this.netIfaces = d.interfaces || [];
|
||||||
|
},
|
||||||
|
// Vul automatisch het eerstvolgende vrije adres in als je een netwerk kiest.
|
||||||
|
pickIp() {
|
||||||
|
const n = this.netList.find(x => x.name === this.install.network);
|
||||||
|
this.install.ip = n ? (n.next_ip || '') : '';
|
||||||
|
},
|
||||||
|
async addNetwork() {
|
||||||
|
const d = await this.rpc('/api/networks', this.newNet);
|
||||||
|
this.toast(d.msg || (d.ok ? 'Aangemaakt' : 'Fout'), d.ok ? 'ok' : 'er');
|
||||||
|
if (d.ok) {
|
||||||
|
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
|
||||||
|
this.modal = null;
|
||||||
|
this.loadNetworks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteNetwork(name) {
|
||||||
|
if (!confirm('Netwerk ' + name + ' verwijderen?')) return;
|
||||||
|
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
|
||||||
|
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
|
||||||
|
this.loadNetworks();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cryptografisch willekeurige waarde voor admin-tokens en wachtwoorden.
|
||||||
|
// Lokaal gegenereerd, dus het geheim gaat niet heen en weer over het net.
|
||||||
|
randomSecret(bytes = 24) {
|
||||||
|
const buf = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
return btoa(String.fromCharCode(...buf))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Containers binnen een stack ──────────────────────────────────────────
|
||||||
|
async toggleContainers(stack) {
|
||||||
|
const cur = this.cpanel[stack] || { open: false, loading: false, items: [] };
|
||||||
|
if (cur.open) { this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: false } }; return; }
|
||||||
|
this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: true, loading: true } };
|
||||||
|
await this.loadContainers(stack);
|
||||||
|
},
|
||||||
|
async loadContainers(stack) {
|
||||||
|
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/containers`);
|
||||||
|
const cur = this.cpanel[stack] || { open: true };
|
||||||
|
this.cpanel = { ...this.cpanel,
|
||||||
|
[stack]: { ...cur, loading: false, items: d.containers || [] } };
|
||||||
|
},
|
||||||
|
async containerAction(stack, container, action) {
|
||||||
|
const d = await this.rpc(
|
||||||
|
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
|
||||||
|
this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || 'Fout'), d.ok ? 'ok' : 'er');
|
||||||
|
await this.loadContainers(stack);
|
||||||
|
this.loadStacks();
|
||||||
|
},
|
||||||
|
async openContainerLogs(stack, container) {
|
||||||
|
const d = await this.rpc(
|
||||||
|
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/logs`);
|
||||||
|
this.logs = { title: container + ' · logs', content: d.logs || '(geen output)' };
|
||||||
|
this.modal = 'logs';
|
||||||
|
},
|
||||||
|
|
||||||
async openLogs(name) {
|
async openLogs(name) {
|
||||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
|
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
|
||||||
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
|
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
|
||||||
|
|
@ -1175,9 +1424,11 @@ function app() {
|
||||||
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
|
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
|
||||||
loading: true,
|
loading: true,
|
||||||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||||
fields: [], values: {}, preview_files: {}
|
fields: [], values: {}, preview_files: {},
|
||||||
|
network: '', ip: ''
|
||||||
};
|
};
|
||||||
this.modal = 'install';
|
this.modal = 'install';
|
||||||
|
if (!this.netList.length) await this.loadNetworks();
|
||||||
const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid });
|
const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid });
|
||||||
this.install.loading = false;
|
this.install.loading = false;
|
||||||
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
|
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
|
||||||
|
|
@ -1231,6 +1482,7 @@ function app() {
|
||||||
repo_id: this.install.repo_id,
|
repo_id: this.install.repo_id,
|
||||||
instance: this.install.instance || this.install.stack,
|
instance: this.install.instance || this.install.stack,
|
||||||
};
|
};
|
||||||
|
if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; }
|
||||||
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; }
|
||||||
const d = await this.rpc('/api/store/install', body);
|
const d = await this.rpc('/api/store/install', body);
|
||||||
|
|
@ -1261,7 +1513,12 @@ function app() {
|
||||||
this.loadAudit();
|
this.loadAudit();
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSettings() { const d = await this.rpc('/api/settings'); this.settings = d || {}; await this.loadAppStore(); },
|
async loadSettings() {
|
||||||
|
const d = await this.rpc('/api/settings'); this.settings = d || {};
|
||||||
|
await this.loadAppStore();
|
||||||
|
await this.loadUsers();
|
||||||
|
await this.loadNetworks();
|
||||||
|
},
|
||||||
autoSave() {
|
autoSave() {
|
||||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
this.saveTimer = setTimeout(async () => {
|
this.saveTimer = setTimeout(async () => {
|
||||||
|
|
|
||||||
86
tests/test_compose_transform.py
Normal file
86
tests/test_compose_transform.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Poorten → eigen IP-adres (macvlan/ipvlan)."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||||
|
|
||||||
|
from core import compose_transform as ct
|
||||||
|
|
||||||
|
BASIS = """\
|
||||||
|
services:
|
||||||
|
vaultwarden:
|
||||||
|
image: vaultwarden/server:latest
|
||||||
|
ports:
|
||||||
|
- "8222:80"
|
||||||
|
volumes:
|
||||||
|
- /data:/data
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_poorten_verdwijnen_en_ip_wordt_toegekend():
|
||||||
|
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
||||||
|
svc = doc["services"]["vaultwarden"]
|
||||||
|
assert "ports" not in svc
|
||||||
|
assert svc["networks"] == {"lan": {"ipv4_address": "192.168.1.240"}}
|
||||||
|
assert doc["networks"] == {"lan": {"external": True}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_overige_instellingen_blijven_intact():
|
||||||
|
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
||||||
|
svc = doc["services"]["vaultwarden"]
|
||||||
|
assert svc["image"] == "vaultwarden/server:latest"
|
||||||
|
assert svc["volumes"] == ["/data:/data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_alleen_de_publicerende_service_krijgt_het_ip():
|
||||||
|
tekst = """\
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
"""
|
||||||
|
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.5"))
|
||||||
|
assert doc["services"]["web"]["networks"] == {"lan": {"ipv4_address": "10.0.0.5"}}
|
||||||
|
# De database blijft in hetzelfde netwerk zodat web hem kan vinden.
|
||||||
|
assert doc["services"]["db"]["networks"] == ["lan"]
|
||||||
|
assert "ports" not in doc["services"]["db"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expliciete_service_kan_gekozen_worden():
|
||||||
|
tekst = "services:\n a:\n image: x\n b:\n image: y\n ports: ['1:1']\n"
|
||||||
|
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.9", only_service="a"))
|
||||||
|
assert doc["services"]["a"]["networks"] == {"lan": {"ipv4_address": "10.0.0.9"}}
|
||||||
|
assert doc["services"]["b"]["networks"] == ["lan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zonder_poorten_krijgt_de_eerste_service_het_ip():
|
||||||
|
doc = yaml.safe_load(ct.to_dedicated_ip(
|
||||||
|
"services:\n solo:\n image: x\n", "lan", "10.0.0.2"))
|
||||||
|
assert doc["services"]["solo"]["networks"] == {"lan": {"ipv4_address": "10.0.0.2"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tekst,fout", [
|
||||||
|
("dit: [is geen: geldige yaml", "geldige YAML"),
|
||||||
|
("version: '3'\n", "geen services"),
|
||||||
|
("- een\n- lijst\n", "mapping"),
|
||||||
|
])
|
||||||
|
def test_foutmeldingen(tekst, fout):
|
||||||
|
with pytest.raises(ct.TransformError) as e:
|
||||||
|
ct.to_dedicated_ip(tekst, "lan", "10.0.0.1")
|
||||||
|
assert fout in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_onbekende_service_geeft_fout():
|
||||||
|
with pytest.raises(ct.TransformError):
|
||||||
|
ct.to_dedicated_ip(BASIS, "lan", "10.0.0.1", only_service="bestaat-niet")
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_ports():
|
||||||
|
assert ct.published_ports(BASIS) == ["8222:80"]
|
||||||
|
assert ct.published_ports("services:\n a:\n image: x\n") == []
|
||||||
77
tests/test_networks.py
Normal file
77
tests/test_networks.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Validatie en IP-toewijzing voor macvlan/ipvlan-netwerken."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||||
|
|
||||||
|
from core import networks
|
||||||
|
|
||||||
|
GELDIG = {"name": "lan", "driver": "macvlan", "parent": "eth0",
|
||||||
|
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
|
||||||
|
"ip_range": "192.168.1.240/28"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_geldige_definitie():
|
||||||
|
assert networks.validate(GELDIG) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("wijziging,fragment", [
|
||||||
|
({"name": "../boos"}, "netwerknaam"),
|
||||||
|
({"name": ""}, "netwerknaam"),
|
||||||
|
({"driver": "bridge"}, "macvlan"),
|
||||||
|
({"parent": "eth0; rm -rf /"}, "host-interface"),
|
||||||
|
({"subnet": "geen-subnet"}, "subnet"),
|
||||||
|
({"gateway": "10.9.9.1"}, "gateway ligt buiten"),
|
||||||
|
({"gateway": "nogal-fout"}, "gateway-adres"),
|
||||||
|
({"ip_range": "10.9.9.0/24"}, "range ligt buiten"),
|
||||||
|
({"ip_range": "onzin"}, "IP-range"),
|
||||||
|
])
|
||||||
|
def test_ongeldige_definities(wijziging, fragment):
|
||||||
|
net = {**GELDIG, **wijziging}
|
||||||
|
assert fragment in networks.validate(net)
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_free_ip_respecteert_range_en_gateway(monkeypatch):
|
||||||
|
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||||
|
# .240/28 loopt van .240 t/m .255; .240 is het netwerkadres, dus .241 eerst.
|
||||||
|
assert networks.next_free_ip(GELDIG) == "192.168.1.241"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_free_ip_slaat_bezette_adressen_over(monkeypatch):
|
||||||
|
monkeypatch.setattr(networks, "used_ips",
|
||||||
|
lambda naam: {"192.168.1.241", "192.168.1.242"})
|
||||||
|
assert networks.next_free_ip(GELDIG) == "192.168.1.243"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_free_ip_houdt_rekening_met_reserveringen(monkeypatch):
|
||||||
|
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||||
|
assert networks.next_free_ip(GELDIG, {"192.168.1.241"}) == "192.168.1.242"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_free_ip_zonder_range_gebruikt_subnet(monkeypatch):
|
||||||
|
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||||
|
net = {**GELDIG, "ip_range": ""}
|
||||||
|
assert networks.next_free_ip(net) == "192.168.1.2" # .1 is de gateway
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_free_ip_leeg_als_alles_bezet(monkeypatch):
|
||||||
|
bezet = {f"192.168.1.{i}" for i in range(240, 256)}
|
||||||
|
monkeypatch.setattr(networks, "used_ips", lambda naam: bezet)
|
||||||
|
assert networks.next_free_ip(GELDIG) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ip,verwacht", [
|
||||||
|
("192.168.1.240", True),
|
||||||
|
("192.168.2.1", False),
|
||||||
|
("geen-ip", False),
|
||||||
|
])
|
||||||
|
def test_ip_in_network(ip, verwacht):
|
||||||
|
assert networks.ip_in_network(ip, GELDIG) is verwacht
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_weigert_ongeldige_naam():
|
||||||
|
ok, msg = networks.remove("../etc")
|
||||||
|
assert ok is False
|
||||||
|
assert "netwerknaam" in msg
|
||||||
82
tests/test_stack_api.py
Normal file
82
tests/test_stack_api.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Routes rond stacks: containerbeheer en de netwerkkeuze bij installeren."""
|
||||||
|
from conftest import login
|
||||||
|
|
||||||
|
|
||||||
|
def test_containers_van_onbekende_stack(client):
|
||||||
|
login(client)
|
||||||
|
r = client.get("/api/stacks/bestaatniet/containers")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_containeractie_alleen_binnen_eigen_stack(client):
|
||||||
|
"""De containernaam wordt getoetst aan compose_ps van díé stack, zodat deze
|
||||||
|
route nooit een willekeurige container op de host kan raken."""
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/stacks/mijnstack/containers/traefik/stop",
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "hoort niet bij deze stack" in r.get_json()["msg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_onbekende_containeractie(client):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/stacks/mijnstack/containers/web/verwijder",
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_containerlogs_alleen_binnen_eigen_stack(client):
|
||||||
|
login(client)
|
||||||
|
r = client.get("/api/stacks/mijnstack/containers/vreemde/logs")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_weigert_onbekend_netwerk(client):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/store/install",
|
||||||
|
json={"stack": "vaultwarden", "repo_id": "server-up",
|
||||||
|
"instance": "vw", "network": "bestaatniet"},
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "niet geconfigureerd" in r.get_json()["msg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_weigert_ip_buiten_subnet(client, env):
|
||||||
|
csrf = login(client)
|
||||||
|
env["core"].patch({"NETWORKS": [{
|
||||||
|
"name": "lan", "driver": "macvlan", "parent": "eth0",
|
||||||
|
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1"}]})
|
||||||
|
r = client.post("/api/store/install",
|
||||||
|
json={"stack": "vaultwarden", "repo_id": "server-up",
|
||||||
|
"instance": "vw", "network": "lan", "ip": "10.9.9.9"},
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "buiten het subnet" in r.get_json()["msg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_netwerk_toevoegen_valideert(client):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/networks",
|
||||||
|
json={"name": "lan", "driver": "macvlan",
|
||||||
|
"parent": "eth0; id", "subnet": "192.168.1.0/24"},
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_netwerk_verwijderen_onbekend(client):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.delete("/api/networks/bestaatniet", headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_gereserveerde_ips_worden_gelezen_uit_stacks(client, env):
|
||||||
|
"""Een geïnstalleerde stack houdt zijn adres, ook als hij gestopt is."""
|
||||||
|
import json
|
||||||
|
login(client)
|
||||||
|
stack = env["lib"] / "vaultwarden"
|
||||||
|
stack.mkdir()
|
||||||
|
(stack / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.241"}))
|
||||||
|
import app as app_module
|
||||||
|
with app_module.app.test_request_context():
|
||||||
|
assert "192.168.1.241" in app_module._reserved_ips()
|
||||||
|
assert "192.168.1.241" not in app_module._reserved_ips(exclude_stack="vaultwarden")
|
||||||
Loading…
Reference in a new issue