diff --git a/.forgejo/workflows/deploy-prod.yml b/.forgejo/workflows/deploy-prod.yml index 1375954..97dfad2 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 6ec0fed..a4e1865 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9460c99..19518ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc *.pyo .env +.pytest_cache/ +venv/ diff --git a/Dockerfile b/Dockerfile index e5c8738..eed325d 100644 --- a/Dockerfile +++ b/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"] diff --git a/VERSION b/VERSION index de41f35..61f0ee2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.60 +0.5.00-beta diff --git a/docker-compose.yml b/docker-compose.yml index 85cca71..6456365 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/fix-config.sh b/fix-config.sh deleted file mode 100644 index 0ec1089..0000000 --- a/fix-config.sh +++ /dev/null @@ -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)'." diff --git a/server-up/app.py b/server-up/app.py index 40e955d..862ba0b 100644 --- a/server-up/app.py +++ b/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,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/", 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//", 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//", 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//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//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//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//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//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//sync", methods=["GET", "POST"]) +@app.route("/api/repos//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//sync", methods=["GET", "POST"]) +@app.route("/api/modrepos//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") diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index c43d585..40009bf 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -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::`, 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) diff --git a/server-up/core/audit.py b/server-up/core/audit.py index 5b3e10c..d428eb7 100644 --- a/server-up/core/audit.py +++ b/server-up/core/audit.py @@ -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 diff --git a/server-up/core/auth.py b/server-up/core/auth.py new file mode 100644 index 0000000..dbbff6c --- /dev/null +++ b/server-up/core/auth.py @@ -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)) diff --git a/server-up/core/boilerplates.py b/server-up/core/boilerplates.py index e26b814..ffc8353 100644 --- a/server-up/core/boilerplates.py +++ b/server-up/core/boilerplates.py @@ -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, diff --git a/server-up/core/git.py b/server-up/core/git.py index 7886ace..7119138 100644 --- a/server-up/core/git.py +++ b/server-up/core/git.py @@ -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" diff --git a/server-up/core/jobs.py b/server-up/core/jobs.py index 01bb870..e0a134f 100644 --- a/server-up/core/jobs.py +++ b/server-up/core/jobs.py @@ -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"]} diff --git a/server-up/requirements.txt b/server-up/requirements.txt index def39ff..1b05654 100644 --- a/server-up/requirements.txt +++ b/server-up/requirements.txt @@ -1,3 +1,4 @@ flask>=3.0 pyyaml>=6.0 jinja2>=3.1 +waitress>=3.0 diff --git a/server-up/templates/index.html b/server-up/templates/index.html index a25b395..342ae28 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -6,9 +6,11 @@ Server Up - - - + + + + - +