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

+
+
+ + +
+ +
+ + +