From f40fd39ed9ddea037631fbc1a5daaa8b4b940cd3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 26 Jul 2026 22:04:08 +0200 Subject: [PATCH] v0.5.30-beta - rollen, app-store-filter en twee planningsfouten Rollen per gebruiker: - Drie rollen: beheerder (alles), operator (stacks, containers en backups) en alleen lezen. Afgedwongen in de before_request-guard, niet alleen in de UI. - Eigen wachtwoord wijzigen mag iedereen; de laatste beheerder kan niet gedegradeerd of verwijderd worden. - Proxy-gebruikers zonder eigen account krijgen AUTH.proxy_role; bestaat er wel een lokaal account met die naam, dan wint dat account. - Accounts van voor deze versie hebben geen rol en gelden als beheerder. Rechtenescalatie verholpen: - set_password verving het hele gebruikersrecord, waardoor het role-veld wegviel. Een ontbrekende rol geldt als beheerder (nodig voor bestaande accounts), dus elke operator of viewer kon zichzelf tot beheerder promoveren door zijn eigen wachtwoord te wijzigen. Gevonden bij het naspelen van de rollenflow tegen een draaiende instantie. Geplande backups draaiden nooit: - De scheduler stempelde ook taken af die zichzelf hadden overgeslagen. De backup-taak bewaakt zelf het ingestelde uur, dus die werd afgestempeld op het moment van de eerste tick; 24 uur later viel het volgende moment op precies dat verkeerde tijdstip. Een taak stempelt nu alleen af bij True. De backup-taak draait op een kort interval en beslist zelf, zodat een gewijzigde planning meteen werkt. BACKUP_SCHEDULE_DAY toegevoegd voor wekelijks. Verder: - Zoekveld en tagfilters in de app store; repo's zonder treffers vallen weg. - Opstartcontrole op de meegeleverde front-end-bestanden: mislukt het downloaden tijdens de image-build, dan laadde de UI zonder opmaak terwijl de server prima leek te draaien. Nu een duidelijke melding in het log. - 227 tests (15 nieuwe voor rollen, 15 voor de scheduler). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 55 +++++++++++ VERSION | 2 +- server-up/app.py | 115 ++++++++++++++++++---- server-up/core/__init__.py | 1 + server-up/core/auth.py | 68 ++++++++++++- server-up/core/scheduler.py | 15 ++- server-up/templates/index.html | 87 +++++++++++++++-- server-up/translations/en.json | 11 ++- server-up/translations/nl.json | 11 ++- tests/test_i18n.py | 14 ++- tests/test_roles.py | 173 +++++++++++++++++++++++++++++++++ tests/test_scheduler.py | 146 ++++++++++++++++++++++++++++ 12 files changed, 660 insertions(+), 38 deletions(-) create mode 100644 tests/test_roles.py create mode 100644 tests/test_scheduler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 175b7ed..5d97709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +# v0.5.30-beta โ€” Rollen, app-store-filter en twee planningsfouten + +## ๐Ÿ‘ฅ Rollen per gebruiker + +Tot nu toe had elk account volledige toegang. Nu drie rollen: + +| Rol | Mag | +|---|---| +| **Beheerder** | alles | +| **Operator** | stacks, containers en backups beheren โ€” niet de instellingen, gebruikers, repo's, netwerken of modules | +| **Alleen lezen** | alleen bekijken | + +- Afgedwongen in de `before_request`-guard, niet alleen in de interface. +- Je eigen wachtwoord wijzigen mag iedereen, ongeacht rol. +- De laatste beheerder kan niet gedegradeerd of verwijderd worden. +- Wie via de reverse proxy binnenkomt zonder eigen account krijgt de rol uit + `AUTH.proxy_role`; bestaat er wรฉl een lokaal account met die naam, dan wint + dat account. +- Accounts van vรณรณr deze versie hebben geen rol en gelden als beheerder. + +## ๐Ÿ”’ Rechtenescalatie verholpen + +Bij het wijzigen van een wachtwoord werd het hele gebruikersrecord vervangen, +waardoor het `role`-veld wegviel. Omdat een ontbrekende rol als beheerder geldt +(nodig voor bestaande accounts), kon **elke operator of viewer zichzelf tot +beheerder promoveren door zijn eigen wachtwoord te wijzigen.** Gevonden bij het +naspelen van de rollenflow; de rol blijft nu behouden en een test dekt het af. + +## โฐ Geplande backups draaiden nooit + +De scheduler stempelde ook taken af die zichzelf hadden overgeslagen. De +backup-taak bewaakt zelf of het het ingestelde uur is, dus die werd afgestempeld +op het moment van de eerste tick โ€” waarna het volgende moment 24 uur later op +precies dat verkeerde tijdstip viel. Resultaat: de geplande backup kwam nooit +aan de beurt. + +Een taak stempelt nu alleen af als hij `True` teruggeeft. De backup-taak draait +daardoor op een kort interval en bepaalt zelf wanneer het zover is, zodat een +gewijzigde planning ook meteen werkt zonder herstart. Voor wekelijkse backups is +`BACKUP_SCHEDULE_DAY` toegevoegd. + +## ๐Ÿ”Ž Zoeken en filteren in de app store + +Zoekveld over naam, omschrijving en tags, plus klikbare tagfilters (de veertien +meestgebruikte, op frequentie gesorteerd). Repo's zonder treffers vallen weg. + +## ๐Ÿ”ง Overig + +- Opstartcontrole op de meegeleverde front-end-bestanden. Mislukt het downloaden + tijdens de image-build, dan laadde de interface zonder opmaak terwijl de + server prima leek te draaien; nu staat er een duidelijke melding in het log. +- 227 tests. + +--- + # v0.5.20-beta โ€” Vertalingen, SSO-scherm, complete backups en app-updates ## ๐ŸŒ Vertalingen en SSO diff --git a/VERSION b/VERSION index fc18b1a..bcc01d8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.20-beta +0.5.30-beta diff --git a/server-up/app.py b/server-up/app.py index ef995b1..5ce525e 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -1,7 +1,7 @@ """Server Up โ€” Docker Manager.""" from __future__ import annotations import inspect, json, os, re, secrets, shutil, subprocess, sys, time -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path # Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core' @@ -152,6 +152,30 @@ PUBLIC_PATHS = frozenset({ }) +# Routes die alleen een beheerder mag aanroepen: alles wat de installatie zelf +# verandert (accounts, instellingen, repo's, netwerken, modules, updates). +# Stacks beheren mag een operator ook; een viewer mag helemaal niets muteren. +ADMIN_ONLY_PREFIXES = ( + "/api/auth/users", "/api/auth/mode", "/api/settings", "/api/networks", + "/api/repos", "/api/modrepos", "/api/modules", "/api/wizard", + "/api/update/", "/api/audit/clear", "/api/docker/restart", + "/api/docker/images", +) + +# Uitzondering: je eigen wachtwoord wijzigen mag iedereen. +ROLE_EXEMPT = ("/api/auth/password", "/api/auth/logout") + + +def _mag_muteren(rol: str, pad: str) -> bool: + if pad in ROLE_EXEMPT: + return True + if rol == "admin": + return True + if rol == "operator": + return not pad.startswith(ADMIN_ONLY_PREFIXES) + return False # viewer + + @app.before_request def require_auth(): p = request.path @@ -163,6 +187,9 @@ def require_auth(): 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 + if not _mag_muteren(auth.current_role(), p): + return jsonify(ok=False, + msg="Je rol geeft geen toegang tot deze actie"), 403 return None if auth.needs_setup(): return jsonify(ok=False, msg="Server Up is nog niet ingesteld", @@ -175,6 +202,7 @@ def api_auth_me(): user = auth.current_user() a = auth.settings() return jsonify(authenticated=bool(user), user=user or "", + role=auth.current_role() if user else "", needs_setup=auth.needs_setup(), mode=a.get("mode", "local"), version=VERSION, csrf_token=auth.csrf_token() if user else "") @@ -233,18 +261,31 @@ def api_auth_password(): @app.route("/api/auth/users") def api_auth_users(): a = auth.settings() - return jsonify(users=auth.users(), mode=a.get("mode", "local"), + return jsonify(users=[{"name": n, "role": auth.role_of(n)} for n in auth.users()], + roles=list(auth.ROLES), + mode=a.get("mode", "local"), proxy_header=a.get("proxy_header", ""), + proxy_role=a.get("proxy_role", "admin"), 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", "")) + ok, msg = auth.create_user(d.get("username", ""), d.get("password", ""), + d.get("role", auth.DEFAULT_ROLE)) if ok: audit.log("auth", "user_add", "ok", ref=d.get("username", ""), - ip=request.remote_addr) + detail=d.get("role", ""), ip=request.remote_addr) + return jsonify(ok=ok, msg=msg), (200 if ok else 400) + + +@app.route("/api/auth/users//role", methods=["PUT"]) +def api_auth_user_role(name): + ok, msg = auth.set_role(name, (request.json or {}).get("role", "")) + if ok: + audit.log("auth", "user_role", "ok", ref=name, + detail=(request.json or {}).get("role", ""), ip=request.remote_addr) return jsonify(ok=ok, msg=msg), (200 if ok else 400) @@ -275,7 +316,10 @@ def api_auth_mode(): 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_rol = d.get("proxy_role", a.get("proxy_role", "admin")) + if proxy_rol not in auth.ROLES: + return jsonify(ok=False, msg="Onbekende rol voor proxy-gebruikers"), 400 + a.update({"mode": mode, "proxy_role": proxy_rol, "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}) @@ -1243,8 +1287,7 @@ def api_backup_create(stack): jobs.log(qq, "section", f"Backup: {stack}") meta = backups.create(stack, reden="handmatig", log_fn=lambda m: jobs.log(qq, "dim", m)) - for weg in backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)): - pass + backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok", f"Backup klaar ({meta['file']})") audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr) jobs.finish(jid, "done") @@ -2111,17 +2154,24 @@ def _apply_boot_repos(): audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url")) -def _geplande_backups(): - """Backup van alle stacks, aangestuurd door de scheduler.""" +def _geplande_backups() -> bool: + """Backup van alle stacks, aangestuurd door de scheduler. + + Retourneert True als er daadwerkelijk gedraaid is; False betekent "nog niet + het juiste moment" en zorgt dat de scheduler de taak nรญรฉt afstempelt. + """ c = cfg.load() plan = (c.get("BACKUP_SCHEDULE") or "off").lower() if plan not in ("daily", "weekly"): - return + return False if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)): - return + return False + # Wekelijks: alleen op de ingestelde weekdag (standaard maandag). + if plan == "weekly" and datetime.now().weekday() != int(c.get("BACKUP_SCHEDULE_DAY") or 0): + return False lib = Path(c["LIBRARY_DIR"]).resolve() if not lib.is_dir(): - return + return False for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): continue @@ -2133,16 +2183,25 @@ def _geplande_backups(): print(f" [backup] {d.name} mislukt: {e}") audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e)) backups.prune() + return True + + +def _check_stack_updates() -> bool: + """Updatecheck voor alle stacks; altijd True zodat de scheduler afstempelt + (check_all geeft een lege dict terug als er geen stacks zijn).""" + stackupdates.check_all(force=True) + return True def _registreer_taken(): - """Periodieke taken aanmelden bij de scheduler.""" - c = cfg.load() - # Dagelijks/wekelijks; de venstercheck in _geplande_backups bepaalt het uur. - uren = 24.0 if (c.get("BACKUP_SCHEDULE") or "off") != "weekly" else 24.0 * 7 - scheduler.register("backups", _geplande_backups, interval_uren=uren) - scheduler.register("stack_updates", - lambda: stackupdates.check_all(force=True), + """Periodieke taken aanmelden bij de scheduler. + + De backup-taak krijgt bewust een kort interval: hij bewaakt zรฉlf of het + juiste uur en de juiste dag is, en stempelt pas af als hij gedraaid heeft. + Zo werkt een gewijzigde planning meteen, zonder herstart. + """ + scheduler.register("backups", _geplande_backups, interval_uren=0.5) + scheduler.register("stack_updates", _check_stack_updates, interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK") @@ -2182,10 +2241,28 @@ def _boot_sync(): # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +def _controleer_assets(): + """Waarschuw als de meegeleverde front-end-bestanden ontbreken. + + Die worden tijdens de image-build gedownload (zie Dockerfile). Mislukt dat, + dan laadt de interface zonder opmaak en zonder Alpine โ€” een lege pagina dus. + Zonder deze melding is dat lastig te herleiden, want de server draait prima. + """ + ontbreekt = [naam for naam in ("vendor/tailwind.js", "vendor/alpine.min.js", + "vendor/htmx.min.js", "fonts/mdi.min.css") + if not (APP / "static" / naam).is_file()] + if ontbreekt: + print("\nโš  Front-end-bestanden ontbreken: " + ", ".join(ontbreekt)) + print(" De interface laadt dan zonder opmaak. Bouw het image opnieuw") + print(" (docker compose build --pull) of draai met SU_DEV=1 lokaal.\n") + return not ontbreekt + + if __name__ == "__main__": port = int(os.environ.get("PORT", 5000)) host = os.environ.get("SU_BIND", "0.0.0.0") print(f"\n๐Ÿš€ Server Up {VERSION} ยท http://{host}:{port}\n") + _controleer_assets() audit.init() i18n.load() print("Boot repos:") diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index a9c8a90..5937e69 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -62,6 +62,7 @@ DEFAULTS = { "BACKUP_MAX_AGE_DAYS": 0, "BACKUP_SCHEDULE": "off", # off | daily | weekly "BACKUP_SCHEDULE_HOUR": 3, + "BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag โ€ฆ 6 = zondag "BACKUP_BEFORE_UPDATE": True, "BACKUP_BEFORE_REMOVE": True, # Laatste uitvoering van geplande taken (beheerd door core.scheduler). diff --git a/server-up/core/auth.py b/server-up/core/auth.py index dbbff6c..41b5829 100644 --- a/server-up/core/auth.py +++ b/server-up/core/auth.py @@ -34,11 +34,16 @@ _FAILS_LOCK = threading.Lock() _MAX_FAILS = 5 _LOCKOUT_SECONDS = 300 +# Rollen, van veel naar weinig rechten. +ROLES = ("admin", "operator", "viewer") +DEFAULT_ROLE = "admin" + DEFAULT_AUTH = { "mode": "local", # local | proxy | both - "users": {}, # {naam: {salt, hash}} + "users": {}, # {naam: {salt, hash, role}} "proxy_header": "Remote-User", "trusted_proxies": [], # IP's of CIDR's die de header mogen zetten + "proxy_role": "admin", # rol voor wie via de proxy binnenkomt "session_hours": 12, } @@ -139,21 +144,51 @@ def needs_setup() -> bool: return not a["users"] -def create_user(name: str, password: str) -> tuple[bool, str]: +def role_of(name: str) -> str: + """Rol van een gebruiker. Accounts van vรณรณr de rollen gelden als admin.""" + rec = settings()["users"].get(name) or {} + rol = rec.get("role") + return rol if rol in ROLES else DEFAULT_ROLE + + +def admins() -> list[str]: + a = settings() + return [n for n in a["users"] if role_of(n) == "admin"] + + +def create_user(name: str, password: str, role: str = DEFAULT_ROLE) -> tuple[bool, str]: name = (name or "").strip() if not name or len(name) > 64: return False, "Ongeldige gebruikersnaam." + if role not in ROLES: + return False, "Onbekende rol." 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) + rec = hash_password(password) + rec["role"] = role + a["users"][name] = rec _save(a) return True, "Account aangemaakt." +def set_role(name: str, role: str) -> tuple[bool, str]: + if role not in ROLES: + return False, "Onbekende rol." + a = settings() + if name not in a["users"]: + return False, "Gebruiker niet gevonden." + # Zonder admin kun je niemand meer beheren en jezelf niet terugzetten. + if role != "admin" and admins() == [name]: + return False, "Dit is de laatste beheerder โ€” die rol kan niet weg." + a["users"][name]["role"] = role + _save(a) + return True, f"Rol van {name} is nu {role}." + + def set_password(name: str, password: str) -> tuple[bool, str]: problem = password_problem(password) if problem: @@ -161,7 +196,12 @@ def set_password(name: str, password: str) -> tuple[bool, str]: a = settings() if name not in a["users"]: return False, "Gebruiker niet gevonden." - a["users"][name] = hash_password(password) + # Het hele record vervangen zou de rol wissen. Een ontbrekende rol geldt als + # admin (voor accounts van vรณรณr de rollen), dus dan zou iedereen zichzelf + # tot beheerder promoveren door z'n wachtwoord te wijzigen. + rec = hash_password(password) + rec["role"] = role_of(name) + a["users"][name] = rec _save(a) return True, "Wachtwoord gewijzigd." @@ -172,6 +212,8 @@ def delete_user(name: str) -> tuple[bool, str]: return False, "Gebruiker niet gevonden." if len(a["users"]) == 1 and a.get("mode") != "proxy": return False, "De laatste gebruiker kan niet verwijderd worden." + if admins() == [name] and a.get("mode") != "proxy": + return False, "Dit is de laatste beheerder." del a["users"][name] _save(a) return True, "Gebruiker verwijderd." @@ -263,6 +305,24 @@ def start_session(name: str, via: str = "local"): session["csrf"] = secrets.token_urlsafe(32) +def current_role() -> str: + """Rol van de huidige gebruiker. + + Wie via de proxy binnenkomt staat niet in `users`; die krijgt de rol uit + `AUTH.proxy_role`, tenzij er toevallig een lokaal account met dezelfde naam + bestaat โ€” dan wint dat account. + """ + naam = session.get("user") + if not naam: + return "" + if naam in settings()["users"]: + return role_of(naam) + if session.get("via") == "proxy": + rol = settings().get("proxy_role") + return rol if rol in ROLES else DEFAULT_ROLE + return DEFAULT_ROLE + + def end_session(): session.clear() diff --git a/server-up/core/scheduler.py b/server-up/core/scheduler.py index 33167d0..53aa096 100644 --- a/server-up/core/scheduler.py +++ b/server-up/core/scheduler.py @@ -25,6 +25,13 @@ _gestart = False def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""): """Registreer een periodieke taak. + `fn` retourneert **True** als het werk daadwerkelijk gedaan is, en False of + None als het moment nog niet daar was. Alleen bij True wordt de taak + afgestempeld. Dat onderscheid is essentieel voor taken die zelf nog een + tijdvenster bewaken: zou een overgeslagen taak ook afgestempeld worden, dan + verschuift het volgende moment naar precies dat verkeerde tijdstip en komt + de taak nooit meer aan de beurt. + `enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan wordt de taak overgeslagen (maar blijft hij geregistreerd). """ @@ -66,13 +73,15 @@ def run_due(log_fn=None) -> list[str]: continue if log_fn: log_fn(f"[scheduler] {naam}") - taak["fn"]() + if taak["fn"]() is not True: + continue # overgeslagen: niet afstempelen _markeer(naam) gedraaid.append(naam) except Exception: - # Eรฉn kapotte taak mag de lus niet stoppen. + # Eรฉn kapotte taak mag de lus niet stoppen. Wรฉl afstempelen, anders + # blijft een structureel falende taak elke tick opnieuw proberen. print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}") - _markeer(naam) # niet blijven herhalen in een lus + _markeer(naam) return gedraaid diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 1ae3679..a0e0284 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -330,14 +330,28 @@ tailwind.config = {
-
-

App Store

+
+
+ + +
+ +
+ + +