server-up/server-up/app.py
Ramon 930dce351b
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 18m31s
v0.8.11-beta - overnemen en verwijderen
- overnemen: een compose-app die buiten Server Up om draait, met een knop
  erbij trekken. Er wordt niets verplaatst of gekopieerd; de stackmap wordt een
  verwijzing naar de map die de gebruiker al heeft. Kopieren zou de relatieve
  paden in zijn compose-bestand breken
- kan Server Up niet bij die map (hij ziet alleen wat via BASE_DIR gekoppeld
  is), dan zegt hij dat met waar de map moet staan
- verwijderen van een container die niet bij een app hoort, met in de vraag dat
  alleen de container weggaat en niet zijn gegevens of image
- hoort hij bij een compose-project, dan staat erbij dat hij terugkomt zodra
  dat project ergens anders start en dat overnemen waarschijnlijk is wat je
  zoekt. Daarom staat overnemen links van verwijderen
- een overgenomen app verwijderen haalt alleen de verwijzing weg; rmtree zou
  daar bovendien op weigeren en halverwege stranden

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-04 00:16:10 +02:00

3986 lines
164 KiB
Python

"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
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'
APP = Path(__file__).resolve().parent
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
import yaml
from flask import Flask, Response, jsonify, render_template, request, send_file, session
import core as cfg
from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater
from core import backups, categories, compose_transform, diskspace, networks
from core import notify, paden, pangolin
from core import scheduler, selfupdate
from core import stackupdates
from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates")
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"
BUNDLED_MOD = APP.parent / "modules"
CONTAINER_NAME = os.environ.get("HOSTNAME", os.environ.get("SU_CONTAINER", "server-up"))
# ── Module loading ────────────────────────────────────────────────────────────
def _mod_js(m: Module) -> Path | None:
sd = getattr(m, "_src", None)
if sd:
f = Path(sd) / "static" / "module.js"
if f.exists():
return f
return None
STARTUP_LOG = []
def load_modules():
# Stap 1: Ruim eerder geïnstalleerde CORE modules op uit user_modules
if USER_MOD.exists():
for d in list(USER_MOD.iterdir()):
if d.is_dir() and d.name in CORE:
msg = f"{d.name} (core, verwijderd uit user_modules)"
print(f" {msg}")
STARTUP_LOG.append(msg)
shutil.rmtree(d, ignore_errors=True)
elif d.is_dir():
mf = d / "module.json"
if mf.exists():
try:
meta = json.loads(mf.read_text())
if meta.get("id", d.name) in CORE:
msg = f"{d.name} (core id={meta['id']}, verwijderd)"
print(f" {msg}")
STARTUP_LOG.append(msg)
shutil.rmtree(d, ignore_errors=True)
except Exception:
pass
# Stap 2: Laad optionele modules
active = cfg.load().get("ACTIVE_MODULES")
for mid, cls, d in discover([USER_MOD, BUNDLED_MOD]):
if active is not None and mid not in active:
msg = f"{mid} (uit)"
print(f" {msg}")
STARTUP_LOG.append(msg)
continue
try:
inst = cls()
inst._src = d
if inst.ID in CORE:
msg = f"{mid} (core, overgeslagen)"
print(f" {msg}")
STARTUP_LOG.append(msg)
continue
MODULES.append(inst)
bp = inst.blueprint()
if bp:
try:
app.register_blueprint(bp)
msg = f"{mid} (met routes)"
except Exception as e:
msg = f"{mid} (routes fout: {e})"
else:
msg = f"{mid} (geen routes)"
print(f" {msg}")
STARTUP_LOG.append(msg)
inst.on_load(app)
except Exception as e:
msg = f"{mid}: {e}"
print(f" {msg}")
STARTUP_LOG.append(msg)
def _notify_restart(mid: str) -> tuple[bool, str]:
"""Module is geïnstalleerd; vereist herstart om actief te worden."""
return True, "geïnstalleerd — herstart de container om te activeren"
# ══════════════════════════════════════════════════════════════════════════════
# 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",
})
# 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", "/api/notify/", "/api/appnetworks",
"/api/pangolin", "/api/paths",
)
# 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
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
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",
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 "",
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 "")
@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=[{"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", ""),
d.get("role", auth.DEFAULT_ROLE))
if ok:
audit.log("auth", "user_add", "ok", ref=d.get("username", ""),
detail=d.get("role", ""), ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/auth/users/<name>/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)
@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
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})
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 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
# ── Docker ────────────────────────────────────────────────────────────────────
@app.route("/api/docker/info")
def api_info():
d = docker.info()
d["su_version"] = VERSION
return jsonify(d)
@app.route("/api/version")
def api_version():
return jsonify(version=VERSION)
@app.route("/api/update")
def api_update():
"""Status van de update-check, aangevuld met of bijwerken mogelijk is."""
force = request.args.get("force") in ("1", "true", "yes")
res = updater.check(VERSION, force=force)
st = selfupdate.status()
res.update({
"can_apply": st["can_apply"],
"apply_reason": st["reason"],
"apply_mode": st.get("mode", ""),
"image": st["image"],
"previous_tag": st["previous_tag"],
"channels": list(updater.CHANNELS),
})
return jsonify(res)
@app.route("/api/update/apply", methods=["POST"])
def api_update_apply():
"""Werk Server Up bij naar een tag uit de releases-API.
Draait als job zodat de UI de voortgang van de `docker pull` kan volgen.
Kort na afloop verdwijnt deze container; de front-end pollt daarna /healthz.
"""
d = request.json or {}
tag = (d.get("tag") or "").strip()
if not tag:
info = updater.check(VERSION)
tag = info.get("latest_tag") or ""
if not tag:
return jsonify(ok=False, msg="Geen versie opgegeven en geen release gevonden"), 400
st = selfupdate.status()
if not st["can_apply"]:
return jsonify(ok=False, msg=st["reason"]), 400
jid, q = jobs.create(f"update:{tag}")
gebruiker = auth.current_user() or ""
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Bijwerken naar {tag}")
ok, msg = selfupdate.apply(tag, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("update", "apply", "ok" if ok else "error", ref=tag,
detail=gebruiker)
jobs.finish(jid, "done" if ok else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
updater.invalidate()
return jsonify(ok=True, job_id=jid, tag=tag)
@app.route("/api/update/rollback", methods=["POST"])
def api_update_rollback():
st = selfupdate.status()
if not st["can_apply"]:
return jsonify(ok=False, msg=st["reason"]), 400
if not st.get("previous_tag"):
return jsonify(ok=False, msg="Geen vorige versie bekend"), 400
jid, q = jobs.create("update:rollback")
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("update", "rollback", "ok" if ok else "error",
ref=st.get("previous_tag", ""), ip=request.remote_addr)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid, tag=st["previous_tag"])
@app.route("/api/containers")
def api_containers():
"""Alle containers op deze host, met of ze bij een beheerde stack horen.
Het dashboard telde alles wat de Docker-daemon kent, terwijl de stacklijst
alleen toont wat in LIBRARY_DIR staat. Containers die je buiten Server Up om
hebt gestart — of die er al stonden — waren daardoor wel zichtbaar in de
tellers maar nergens terug te vinden.
"""
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
beheerd = set()
if lib.is_dir():
beheerd = {d.name for d in lib.iterdir()
if d.is_dir() and not d.name.startswith(".") and docker.has_compose(d)}
containers = docker.list_containers()
for c in containers:
# Een container hoort bij ons als zijn compose-project overeenkomt met
# een map in de library, of als zijn projectmap daar onder valt.
stack = c.get("project") or ""
in_lib = False
if c.get("working_dir"):
try:
in_lib = Path(c["working_dir"]).resolve().parent == lib
except Exception:
in_lib = False
c["managed"] = bool(stack and stack in beheerd) or in_lib
c["stack"] = stack if c["managed"] else ""
c["self"] = c["name"] == CONTAINER_NAME
return jsonify(containers=containers,
managed=sum(1 for c in containers if c["managed"]),
total=len(containers),
running=sum(1 for c in containers if c["running"]))
@app.route("/api/containers/<name>/<action>", methods=["POST"])
def api_any_container_action(name, action):
"""Start/stop/herstart een willekeurige container op deze host.
Bedoeld voor containers die niet bij een beheerde stack horen. De eigen
container is uitgezonderd: daarvoor is /api/docker/restart, dat de
herstart netjes afhandelt in plaats van zichzelf midden in een verzoek te
stoppen.
"""
if action not in ("start", "stop", "restart", "remove"):
return jsonify(ok=False, msg="onbekende actie"), 400
if name == CONTAINER_NAME:
return jsonify(ok=False,
msg="Gebruik 'Container herstarten' in de instellingen "
"om Server Up zelf te herstarten."), 400
bestaand = {c["name"] for c in docker.list_containers()}
if name not in bestaand:
return jsonify(ok=False, msg="container niet gevonden"), 404
ok, msg = docker.container_action(name, action)
audit.log("containers", action, "ok" if ok else "error", ref=name,
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt"))
@app.route("/api/containers/<name>/adopt", methods=["POST"])
def api_container_adopt(name):
"""Neem een handmatig gestarte compose-app over als app van Server Up.
Verwijderen laat zo'n app niet verdwijnen: hoort de container bij een
compose-project, dan staat hij er weer zodra dat project ergens anders
start. Overnemen wél — daarna beheert Server Up hem gewoon mee.
Er wordt niets gekopieerd of verplaatst. De stackmap wordt een symlink naar
de map die de gebruiker al heeft. Kopiëren zou de relatieve paden in zijn
compose-bestand breken: die worden opgezocht vanaf de map waar het bestand
staat, en dat is dan opeens een andere.
"""
container = next((c for c in docker.list_containers() if c["name"] == name), None)
if container is None:
return jsonify(ok=False, msg="container niet gevonden"), 404
project = (container.get("project") or "").strip()
werkmap = (container.get("working_dir") or "").strip()
if not project or not werkmap:
return jsonify(ok=False, msg=(
"Deze container is niet met docker compose gestart, dus er is geen "
"map om over te nemen.")), 400
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", project):
return jsonify(ok=False, msg=f"Ongeldige projectnaam: {project[:40]}"), 400
bron = Path(werkmap)
if not bron.is_dir():
# De container draait op de host; wij zien alleen wat via BASE_DIR
# gekoppeld is. Kopiëren zou hier niet helpen — we kunnen er niet bij.
basis = cfg.load().get("BASE_DIR") or cfg.DEFAULTS["BASE_DIR"]
return jsonify(ok=False, msg=(
f"Server Up kan niet bij {werkmap}: die map is niet in de container "
f"gekoppeld. Zet hem onder {basis}, of koppel hem mee in "
f"docker-compose.yml.")), 400
if not docker.has_compose(bron):
return jsonify(ok=False, msg=(
f"Geen compose-bestand in {werkmap}.")), 400
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
lib.mkdir(parents=True, exist_ok=True)
doel = lib / project
if doel.exists() or doel.is_symlink():
return jsonify(ok=False, msg=(
f"Er is al een app met de naam '{project}'.")), 409
if bron.resolve() == doel.resolve().parent or bron.resolve().parent == lib:
return jsonify(ok=False, msg=(
"Deze map staat al in je appsmap; hij hoort dus al bij Server Up. "
"Ontbreekt hij in de lijst, kijk dan of er een compose-bestand in "
"staat.")), 400
try:
doel.symlink_to(bron.resolve())
except OSError as e:
return jsonify(ok=False, msg=f"Overnemen mislukte: {e}"), 500
audit.log("stacks", "adopt", "ok", ref=project, ip=request.remote_addr)
return jsonify(ok=True, name=project, msg=(
f"'{project}' hoort nu bij Server Up. Er is niets verplaatst: de map "
f"{werkmap} blijft waar hij staat."))
@app.route("/api/containers/<name>/logs")
def api_any_container_logs(name):
bestaand = {c["name"] for c in docker.list_containers()}
if name not in bestaand:
return jsonify(ok=False, msg="container niet gevonden"), 404
tail = min(max(int(request.args.get("tail", 200)), 1), 2000)
return jsonify(ok=True, logs=docker.container_logs(name, tail))
@app.route("/api/docker/images")
def api_images():
return jsonify(images=docker.images())
@app.route("/api/docker/images/remove", methods=["POST"])
def api_images_rm():
d = request.json or {}
ok, msg = docker.rmi(d.get("id", ""), d.get("force", False))
if ok:
audit.log("docker", "rmi", "ok", detail=d.get("id"))
return jsonify(ok=ok, msg=msg)
@app.route("/api/docker/images/prune", methods=["POST"])
def api_images_prune():
ok, msg = docker.prune_images()
if ok:
audit.log("docker", "prune", "ok")
return jsonify(ok=ok, msg=msg)
@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)
ok, msg = docker.restart_container(CONTAINER_NAME)
return jsonify(ok=ok, msg=msg)
# ── Projects / Stacks ────────────────────────────────────────────────────────
@app.route("/api/stacks")
def api_stacks():
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if not lib.exists():
return jsonify([])
# Bescherm tegen verkeerde LIBRARY_DIR
if lib == Path("/app").resolve():
return jsonify([])
# Logo/icon-lijst uit de store opbouwen (best effort) voor naam-fallback
icon_map = {}
try:
c = cfg.load()
for repo in c.get("APP_REPOS", []):
for st in git.scan_stacks(repo.get("id", ""), lib, subdir=repo.get("subdir", "")):
key = st.get("dir") or st.get("name")
if key and (st.get("logo_url") or st.get("icon")):
icon_map.setdefault(key, {"logo_url": st.get("logo_url", ""),
"icon": st.get("icon", "")})
except Exception:
pass
bronnen = _geinstalleerde_bronnen()
def _stack_meta(d) -> dict:
mf = d / ".serverup.json"
if mf.exists():
try:
return json.loads(mf.read_text("utf-8"))
except Exception:
pass
return {}
def _stack_logo(d):
# 1) Voorkeur: opgeslagen metadata bij installatie
m = _stack_meta(d)
if m.get("logo_url") or m.get("icon"):
return m.get("logo_url", ""), m.get("icon", "")
# 2) Fallback: match op naam met de store (ook voor oudere installaties)
if d.name in icon_map:
h = icon_map[d.name]; return h["logo_url"], h["icon"]
for key, h in icon_map.items():
if d.name.startswith(key + "-"):
return h["logo_url"], h["icon"]
return "", ""
out = []
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
# Een map zonder compose-bestand werd hier stilzwijgend overgeslagen.
# Dan staat er een map in je stacks-map die nergens meer opduikt: niet
# in de lijst, en de containers die erbij horen belanden onder
# "handmatig gestart". Je ziet een leeg scherm en nergens staat waarom.
#
# De interface hééft er een weergave voor — rode stip, een streepje in
# plaats van een status, geen knoppen — die door dit `continue` nooit te
# zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen.
heeft_compose = docker.has_compose(d)
ct = docker.compose_ps(d, name=d.name) if heeft_compose else []
logo_url, icon = _stack_logo(d)
meta = _stack_meta(d)
out.append({
"name": d.name, "path": str(d), "has_compose": heeft_compose,
"running": any(c["running"] for c in ct),
"containers": ct,
"logo_url": logo_url, "icon": icon,
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
# dan leiden we het alsnog af uit de opgeslagen naam.
"connected": bool(meta.get("connected")),
"editable": bool(meta.get("values")),
# Wat deze stack nodig heeft en niet (meer) draait. De melding bij
# het installeren was eenmalig; haal je de broker later weg, dan
# valt Zigbee2MQTT stil zonder dat iets zegt waarom.
"depends_missing": _ontbrekende_afhankelijkheden(meta, bronnen),
"categories": categories.normalize(
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
})
return jsonify(out)
def _ontbrekende_afhankelijkheden(meta: dict, bronnen: dict) -> list[dict]:
"""De `depends_on` van het bronsjabloon die nergens geïnstalleerd staan."""
bron_naam = meta.get("source") or ""
if not bron_naam:
return []
try:
src = _find_stack_src(bron_naam, meta.get("repo_id", ""))
except Exception:
return []
if not src or not boilerplates.is_boilerplate(src):
return []
uit = []
for dep in boilerplates.metadata(src).get("depends_on") or []:
if not bronnen.get(dep["app"]):
uit.append(dep)
return uit
def _auto_backup(qq, stack: str, config_key: str, reden: str):
"""Backup vóór een actie die bestanden overschrijft of weggooit.
Faalt de backup, dan gaat de actie wél door — anders kun je een kapotte
stack niet meer opruimen. De melding komt wel in het joblog.
"""
if not cfg.load().get(config_key):
return
try:
meta = backups.create(stack, reden=reden,
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok", f"Backup vooraf: {meta['file']}")
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
except Exception as e:
jobs.log(qq, "warn", f"Backup vooraf mislukt ({e}) — actie gaat door")
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) 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"):
return jsonify(ok=False, msg="onbekend"), 400
body = request.json or {}
jid, q = jobs.create(f"{action}:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name
lf = lambda m: jobs.log(qq, "dim", m)
try:
jobs.log(qq, "section", f"{action}: {name}")
rc = 0
if action == "start":
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "stop":
rc = docker.compose_down(pdir, log_fn=lf, name=name)
elif action == "restart":
docker.compose_down(pdir, log_fn=lf, name=name)
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "update":
_auto_backup(qq, name, "BACKUP_BEFORE_UPDATE", "voor-update")
docker.compose_pull(pdir, log_fn=lf)
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "remove":
mode = body.get("mode", "stack")
if mode in ("full", "all"):
_auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove")
if docker.has_compose(pdir):
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
if mode in ("full", "all") and pdir.is_dir():
# Een overgenomen app is een symlink naar een map van de
# gebruiker zelf. Die map is niet van ons; alleen de
# verwijzing halen we weg. `shutil.rmtree` zou hier
# bovendien weigeren ("Cannot call rmtree on a symbolic
# link") en de verwijdering halverwege laten stranden.
if pdir.is_symlink():
doelmap = os.path.realpath(pdir)
pdir.unlink()
jobs.log(qq, "ok", f"Losgekoppeld; {doelmap} blijft staan")
else:
shutil.rmtree(pdir)
jobs.log(qq, "ok", "Bestanden verwijderd")
if mode == "all":
dd = Path(cfg.load()["DATA_DIR"]).resolve() / name
if dd.is_dir():
shutil.rmtree(dd)
jobs.log(qq, "ok", "Data verwijderd")
elif action == "backup":
# Ging eerder via een tar-subprocess waarvan de returncode
# genegeerd werd — een mislukte backup gold dan als succes.
meta = backups.create(name, reden="handmatig",
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok", f"Backup → {meta['file']} ({meta['size']} bytes)")
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if rc == 0 else "error",
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/stacks/<name>/env")
def api_stack_env(name):
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, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
inhoud = (request.json or {}).get("content", "")
# Compose leest dit bestand regel voor regel; een regel zonder '=' wordt
# stilzwijgend genegeerd en dan mist er ineens een wachtwoord.
for nr, regel in enumerate(inhoud.splitlines(), 1):
kaal = regel.strip()
if not kaal or kaal.startswith("#"):
continue
if "=" not in kaal:
return jsonify(ok=False,
msg=f"Regel {nr} heeft geen '=': {kaal[:60]}"), 400
docker.write_env(d, inhoud)
audit.log("stacks", "env_edit", "ok", ref=name, ip=request.remote_addr)
return jsonify(ok=True)
@app.route("/api/stacks/<name>/credentials")
def api_stack_credentials(name):
"""Waarmee log je bij deze app in?
De wachtwoorden zijn vaak door Server Up gegenereerd; zonder deze route kun
je ze nergens meer terugvinden zonder het compose-bestand of `.env` open te
slaan.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
if auth.role_of(auth.current_user() or "") != "admin":
return jsonify(ok=False, msg="Alleen een beheerder mag inloggegevens zien"), 403
meta = _stack_meta_lezen(d)
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
velden = boilerplates.fields(bron) if bron else []
waarden = {**(meta.get("values") or {}), **_geheimen_uit_env(d, meta)}
items = []
for f in velden:
soort = f.get("credential")
if not soort:
continue
waarde = str(waarden.get(f["name"], "") or "")
if not waarde:
continue
items.append({"kind": soort, "title": f.get("title") or f["name"],
"value": waarde, "secret": bool(f.get("secret"))})
# Het adres waarop de app draait: eigen IP, anders de gepubliceerde poort.
adres = ""
try:
poorten = compose_transform.published_ports(docker.read_compose(d))
except Exception:
poorten = []
if meta.get("ip"):
adres = f"http://{meta['ip']}"
elif poorten:
eerste = str(poorten[0]).strip('"').split(":")
if len(eerste) >= 2 and eerste[-2].isdigit():
adres = f"http://{request.host.split(':')[0]}:{eerste[-2]}"
if items:
audit.log("stacks", "credentials_view", "ok", ref=name,
ip=request.remote_addr)
return jsonify(ok=True, address=adres, items=items,
pangolin_url=meta.get("pangolin_url", ""),
# Apps als Vaultwarden en Immich maken het eerste account aan
# bij het eerste bezoek; dan valt er hier niets te tonen.
self_signup=not items)
@app.route("/api/stacks/<name>/compose")
def api_stack_compose(name):
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, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
content = (request.json or {}).get("content", "")
# Eerst valideren, dan pas schrijven: een typefout maakt anders een
# draaiende stack onstartbaar zonder dat je het merkt.
ok, err = docker.validate_compose_text(content, d)
if not ok:
return jsonify(ok=False, msg=f"Compose is ongeldig:\n{err}"), 400
docker.write_compose(d, content)
audit.log("stacks", "compose_edit", "ok", ref=name, ip=request.remote_addr)
return jsonify(ok=True)
def _stack_meta_lezen(d: Path) -> dict:
mf = d / ".serverup.json"
if mf.exists():
try:
return json.loads(mf.read_text("utf-8"))
except Exception:
pass
return {}
def _stack_meta_schrijven(d: Path, updates: dict):
meta = _stack_meta_lezen(d)
meta.update(updates)
mf = d / ".serverup.json"
mf.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
# Hier stond tot v0.7.60 elk wachtwoord in, wereldleesbaar. De geheimen zijn
# nu naar .env verhuisd, maar de rechten horen hoe dan ook beperkt.
try:
mf.chmod(0o600)
except OSError:
pass
def _splits_geheimen(velden: list[dict], waarden: dict) -> tuple[dict, list[str]]:
"""Scheid de geheime waarden van de rest.
Wat overblijft gaat naar `.serverup.json` voor het herconfiguratieformulier;
de geheimen staan in `.env` en worden daar weer opgehaald.
"""
geheim = {f["name"] for f in velden if f.get("secret")}
zonder = {k: v for k, v in (waarden or {}).items() if k not in geheim}
return zonder, sorted(n for n in geheim if str((waarden or {}).get(n, "")).strip())
def _env_lezen(d: Path) -> dict:
"""`.env` als dictionary."""
env = {}
for regel in docker.read_env(d).splitlines():
if "=" in regel and not regel.lstrip().startswith("#"):
sleutel, _, waarde = regel.partition("=")
env[sleutel.strip()] = waarde
return env
def _geheimen_uit_env(d: Path, meta: dict) -> dict:
"""De geheime waarden terug uit `.env`, via de bewaarde koppeling.
`secret_keys` staat in `.serverup.json` en zegt precies welk veld bij welke
`.env`-sleutel hoort, zodat we niet hoeven te raden.
"""
koppeling = meta.get("secret_keys") or {}
if not koppeling:
return {}
env = _env_lezen(d)
return {veld: env[sleutel] for veld, sleutel in koppeling.items()
if sleutel in env}
@app.route("/api/stacks/<name>/config")
def api_stack_config(name):
"""Opgeslagen installatiekeuzes plus het actuele veldschema.
Daarmee kan de interface hetzelfde formulier opnieuw tonen in plaats van de
gebruiker het compose-bestand met de hand te laten bewerken.
"""
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
meta = _stack_meta_lezen(d)
waarden = meta.get("values") or {}
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if not waarden or not bron:
return jsonify(
ok=False, editable=False,
msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte "
"keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. "
"Wijzigen kan via 'compose bewerken'."))
# De geheimen staan niet in .serverup.json maar in .env; zonder dit zou het
# formulier ze leeg tonen en zou opslaan ze wissen.
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
return jsonify(ok=True, editable=True,
fields=_annotate_fields(boilerplates.fields(bron)),
values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""),
ips=meta.get("ips") or {},
connected=bool(meta.get("connected")),
app_networks=meta.get("app_networks") or [],
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name))
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
def api_stack_reconfigure(name):
"""Render de stack opnieuw met gewijzigde waarden.
Maakt eerst een backup; loopt het renderen of valideren mis, dan wordt die
teruggezet zodat je nooit met een half werkende stack achterblijft.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
body = request.json or {}
nieuwe_waarden = body.get("values") or {}
meta = _stack_meta_lezen(d)
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if not bron:
return jsonify(ok=False, msg="Bronsjabloon niet gevonden"), 400
net_name = (body.get("network") if "network" in body else meta.get("network")) or ""
net_ip = (body.get("ip") if "ip" in body else meta.get("ip")) or ""
net_ips = (body.get("ips") if "ips" in body else meta.get("ips")) or {}
if not isinstance(net_ips, dict):
net_ips = {}
if net_name and net_ips:
net = _network_by_name(net_name)
if net:
al_van_ons = set((meta.get("ips") or {}).values()) | {meta.get("ip") or ""}
net_ips, fout, code = _ip_mapping(net, net_name, net_ips, al_van_ons)
if fout:
return jsonify(ok=False, msg=fout), code
net_ip = ""
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
if "app_networks" in body or "connect_apps" in body:
app_netten = _gekozen_app_netwerken(body, gedeeld_net)
else:
app_netten = [n for n in (meta.get("app_networks") or []) if n] or (
[gedeeld_net] if meta.get("connected") else [])
verbinden = bool(app_netten)
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
ontbreekt = boilerplates.ontbrekende_verplichte(bron, nieuwe_waarden)
if ontbreekt:
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
missing=ontbreekt), 400
jid, q = jobs.create(f"reconfigure:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
backup_naam = ""
try:
jobs.log(qq, "section", f"Instellingen wijzigen: {name}")
try:
backup_naam = backups.create(name, reden="voor-herconfiguratie",
log_fn=lf)["file"]
except Exception as e:
jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}")
jobs.finish(jid, "error"); jobs.done(qq); return
boilerplates.render_to_dir(bron, d, nieuwe_waarden)
jobs.log(qq, "ok", "Opnieuw gerenderd")
cf = docker.find_compose(d)
# Ook hier de geheimen uit het compose-bestand halen, anders staan
# ze na een wijziging alsnog leesbaar in de stackmap.
velden_bron = boilerplates.fields(bron)
secret_keys = {}
if cf:
try:
nieuw_txt, env_geheim, secret_keys = compose_transform.geheimen_naar_env(
cf.read_text(encoding="utf-8"), velden_bron, nieuwe_waarden)
if env_geheim:
cf.write_text(nieuw_txt, encoding="utf-8")
docker.write_env(d, compose_transform.env_samenvoegen(
docker.read_env(d), env_geheim))
jobs.log(qq, "ok",
f"{len(env_geheim)} geheim(en) in .env bijgewerkt")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
try:
poorten_meta = compose_transform.poorten_per_service(
cf.read_text(encoding="utf-8")) if cf else []
except Exception:
poorten_meta = []
if net_name and cf:
net = _network_by_name(net_name)
if net and net_ips:
cf.write_text(compose_transform.to_dedicated_ips(
cf.read_text(encoding="utf-8"), net_name, net_ips),
encoding="utf-8")
for svc, adres in net_ips.items():
jobs.log(qq, "ok", f"Eigen IP-adres voor {svc}: {adres}")
elif net and net_ip:
cf.write_text(compose_transform.to_dedicated_ip(
cf.read_text(encoding="utf-8"), net_name, net_ip),
encoding="utf-8")
jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}")
if app_netten:
klaar = []
for naam in app_netten:
ok_net, fout_net = networks.ensure_shared(naam)
if ok_net:
klaar.append(naam)
else:
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
if klaar:
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
jobs.log(qq, "error", f"Compose is ongeldig:\n{err}")
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return
zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
_stack_meta_schrijven(d, {
"values": zichtbaar, "secret_fields": geheime_namen,
"secret_keys": secret_keys,
"network": net_name, "ip": net_ip, "ips": net_ips,
"app_networks": app_netten,
"service_ports": poorten_meta,
"connected": bool(app_netten)})
docker.compose_down(d, log_fn=lf, name=name)
rc = docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok" if rc == 0 else "error",
"Herstart met de nieuwe instellingen" if rc == 0
else "Starten mislukt \u2014 zet de backup terug via Backups")
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
ref=name, ip=request.remote_addr)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
if backup_naam:
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
try:
backups.restore(name, backup_naam, log_fn=lf)
except Exception:
pass
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/stacks/<name>/connect", methods=["POST"])
def api_stack_connect(name):
"""Zet een bestaande stack op het gedeelde netwerk (of haal hem eraf).
Stacks die al bestonden voordat de koppeling er was, zaten alleen in hun
eigen compose-netwerk en konden geen andere app bereiken.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
aan = bool((request.json or {}).get("connect", True))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
jid, q = jobs.create(f"connect:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
try:
if not aan:
jobs.log(qq, "warn",
"Loskoppelen gaat via 'compose bewerken': het "
"networks-blok weghalen en de stack herstarten.")
jobs.finish(jid, "done"); jobs.done(qq); return
jobs.log(qq, "section", f"Koppelen: {name}")
ok_net, fout = networks.ensure_shared(gedeeld_net)
if not ok_net:
jobs.log(qq, "error", f"Netwerk '{gedeeld_net}' niet beschikbaar: {fout}")
jobs.finish(jid, "error"); jobs.done(qq); return
cf = docker.find_compose(d)
origineel = cf.read_text(encoding="utf-8")
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
encoding="utf-8")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
cf.write_text(origineel, encoding="utf-8")
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
jobs.finish(jid, "error"); jobs.done(qq); return
_stack_meta_schrijven(d, {"connected": True})
docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok",
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
"bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr)
jobs.finish(jid, "done")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Containers binnen een stack ──────────────────────────────────────────────
def _stack_containers(name: str) -> list[str]:
"""Namen van de containers die bij deze stack horen."""
lib, d = _stack_dir(name)
if not d or not d.exists():
return []
return [c["name"] for c in docker.compose_ps(d, name=name) if c.get("name")]
@app.route("/api/stacks/<name>/containers")
def api_stack_containers(name):
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
containers = docker.compose_ps(d, name=name)
live = docker.stats([c["name"] for c in containers if c.get("running")])
for c in containers:
c["stats"] = live.get(c["name"], {})
return jsonify(ok=True, containers=containers)
@app.route("/api/stacks/<name>/containers/<container>/<action>", methods=["POST"])
def api_container_action(name, container, action):
"""Start/stop/herstart één container binnen een stack.
De containernaam wordt gecontroleerd tegen de containers die bij déze stack
horen; zo kan deze route nooit een willekeurige container op de host raken.
"""
if action not in ("start", "stop", "restart"):
return jsonify(ok=False, msg="onbekende actie"), 400
if container not in _stack_containers(name):
return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404
ok, msg = docker.container_action(container, action)
audit.log("containers", action, "ok" if ok else "error", ref=container,
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt"))
@app.route("/api/stacks/<name>/containers/<container>/logs")
def api_container_logs(name, container):
if container not in _stack_containers(name):
return jsonify(ok=False, msg="container hoort niet bij deze stack"), 404
tail = min(max(int(request.args.get("tail", 200)), 1), 2000)
return jsonify(ok=True, logs=docker.container_logs(container, tail))
@app.route("/api/stacks/<name>/logs")
def api_stack_logs(name):
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():
# Nooit het token zelf teruggeven — alleen of er één ingesteld is.
repos = cfg.redact(cfg.load()).get("APP_REPOS", [])
# Een repo zonder id gaf hier een KeyError, dus een 500, dus een lege
# repolijst in de interface — en daarmee een app store zonder enkele kaart.
# Eén rare regel in de configuratie hoort de rest niet mee te nemen.
# Twee repo's met dezelfde id zijn niet alleen verwarrend: de interface
# tekent de lijst met `:key="repo.id"`, en dubbele keys zijn voor Alpine
# ongedefinieerd gedrag — je krijgt ontbrekende of verkeerde blokken,
# terwijl de tellers gewoon doorrekenen. Een dalend getal onder een leeg
# scherm, zonder dat er ergens een fout te zien is.
result = []
gezien = set()
for r in repos:
if not isinstance(r, dict) or not r.get("id"):
continue
if r["id"] in gezien:
continue
gezien.add(r["id"])
result.append({**r, "cloned": git.cache_dir(r["id"]).exists()})
return jsonify(result)
@app.route("/api/repos", methods=["POST"])
def api_repos_add():
d = request.json or {}
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()))
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"),
"token": d.get("token", ""), "subdir": d.get("subdir", "")})
cfg.patch({"APP_REPOS": repos})
audit.log("repos", "add", "ok", ref=rid)
return jsonify(ok=True)
@app.route("/api/repos/<rid>", methods=["DELETE"])
def api_repos_del(rid):
# `r["id"]` op een regel zonder id gaf hier een KeyError, dus een 500 —
# dezelfde fout die /api/repos had. Verwijdert alle regels met deze id,
# zodat een dubbele vermelding in één keer weg is.
repos = [r for r in cfg.load().get("APP_REPOS", [])
if not isinstance(r, dict) or r.get("id") != rid]
cfg.patch({"APP_REPOS": repos})
c = git.cache_dir(rid)
if c.exists():
shutil.rmtree(c)
audit.log("repos", "delete", "ok", ref=rid)
return jsonify(ok=True)
@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:
return jsonify(ok=False, msg="niet gevonden")
jid, q = jobs.create(f"sync:{rid}")
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/repos/<rid>/stacks")
def api_repos_stacks(rid):
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return jsonify(stacks=[])
return jsonify(stacks=git.scan_stacks(rid, lib, subdir=repo.get("subdir", "")))
def _is_stack_dir(p: Path) -> bool:
"""True als de map ofwel een legacy compose-stack ofwel een boilerplate is."""
return docker.has_compose(p) or boilerplates.is_boilerplate(p)
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
cache_root = git.cache_dir(rid)
# Poging 1: verwacht pad
expected = cache_root
if repo.get("subdir"):
expected = expected / repo["subdir"]
expected = expected / stack
if expected.exists() and _is_stack_dir(expected):
return expected
# Poging 2: bekende submappen
for fb in ("apps", "stacks", "docker", "compose"):
p = cache_root / fb / stack
if p.exists() and _is_stack_dir(p):
return p
# Poging 3: direct in root
direct = cache_root / stack
if direct.exists() and _is_stack_dir(direct):
return direct
# Poging 4: recursief
if cache_root.exists():
for p in cache_root.rglob("*"):
if p.is_dir() and p.name == stack and ".git" not in str(p) and _is_stack_dir(p):
return p
return None
_SECRET_HINTS = ("token", "password", "passwd", "secret", "apikey", "api_key",
"_key", "salt", "pepper")
def _is_secret_field(f: dict) -> bool:
haystack = f"{f.get('name', '')} {f.get('title', '')}".lower()
return any(h in haystack for h in _SECRET_HINTS)
def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
"""Welke gedeelde netwerken wil de gebruiker voor deze stack?
Nieuw is `app_networks` (een lijst). De oude `connect_apps` (aan/uit) blijft
werken zodat bestaande installaties en scripts niet omvallen.
"""
if isinstance(body.get("app_networks"), list):
bekend = {n["name"] for n in networks.app_netwerken()}
return [n for n in body["app_networks"]
if isinstance(n, str) and n in bekend]
verbinden = body.get("connect_apps")
if verbinden is None:
verbinden = bool(cfg.load().get("CONNECT_APPS_DEFAULT", True))
return [standaard] if verbinden else []
def _geinstalleerde_bronnen() -> dict[str, list[str]]:
"""Per app-sjabloon de stacks die eruit geïnstalleerd zijn.
`api_connectable` leidt hetzelfde af uit `.serverup.json`, maar draait daar
een `docker compose ps` per stack overheen om de containernamen op te halen.
Dat is te traag voor een controle die bij elk openen van de installatiemodal
langskomt; hier is alleen de bron nodig.
"""
uit: dict[str, list[str]] = {}
try:
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
except Exception:
return uit
if not lib.is_dir():
return uit
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
mf = d / ".serverup.json"
if not mf.exists():
continue
try:
bron = (json.loads(mf.read_text("utf-8")) or {}).get("source") or ""
except Exception:
continue
if bron:
uit.setdefault(bron, []).append(d.name)
return uit
def _afhankelijkheden_met_status(meta: dict) -> list[dict]:
"""De depends_on van een sjabloon, aangevuld met wat er al draait."""
bronnen = _geinstalleerde_bronnen()
uit = []
for dep in meta.get("depends_on") or []:
stacks = bronnen.get(dep["app"], [])
uit.append({**dep, "installed": bool(stacks), "stacks": stacks})
return uit
def _ip_mapping(net: dict, net_naam: str, gevraagd: dict,
eigen=()) -> tuple[dict, str, int]:
"""Controleer een mapping container → adres, en vul lege adressen aan.
Een stack met twintig webinterfaces wil er niet één adres, maar een adres
per container die je van buiten wilt bereiken. `eigen` zijn de adressen die
deze stack zelf al gebruikt — bij herconfigureren mag hij die houden zonder
dat ze als "al in gebruik" gelden. Retourneert
(mapping, foutmelding, http-status).
"""
bezet = (_reserved_ips() | networks.used_ips(net_naam)) - set(eigen)
uit: dict[str, str] = {}
for service, adres in gevraagd.items():
if not isinstance(service, str) or not service.strip():
continue
service = service.strip()
adres = (adres or "").strip() if isinstance(adres, str) else ""
if not adres:
adres = networks.next_free_ip(net, bezet)
if not adres:
return {}, f"Geen vrij IP-adres meer voor '{service}'", 400
if not networks.ip_in_network(adres, net):
return {}, f"{adres} ligt buiten het subnet van '{net_naam}'", 400
if adres in bezet:
return {}, f"{adres} is al in gebruik", 409
bezet.add(adres)
uit[service] = adres
return uit, "", 0
def _bezette_poorten() -> set[int]:
"""Alles wat een nieuwe stack in de weg kan zitten.
`docker ps` toont alleen wat Docker publiceert. Draait er een webserver of
een DNS-server rechtstreeks op de host, dan botst je stack daar net zo goed
mee — met een foutmelding die niets over de oorzaak zegt.
"""
bezet: set[int] = set()
try:
bezet |= docker.used_ports()
except Exception:
pass
try:
bezet |= networks.host_luisterpoorten()
except Exception:
pass
return bezet
def _annotate_fields(fields: list[dict]) -> list[dict]:
"""Verrijk de installatievelden met een vrije poort en een secret-vlag.
- poortvelden krijgen een default die nog niet in gebruik is, zodat een
installatie niet strandt op 'port is already allocated';
- geheime velden worden gemarkeerd zodat de UI een genereerknop toont.
"""
bezet = _bezette_poorten()
tz = cfg.tijdzone()
appdata = cfg.load().get("DATA_DIR") or cfg.DEFAULTS["DATA_DIR"]
uitgedeeld: set[int] = set()
for f in fields:
if _is_secret_field(f):
f["secret"] = True
naam = (f.get("name") or "").lower()
# De sjablonen hebben Europe/Amsterdam hard ingevuld staan; dat klopt
# alleen toevallig. De tijdzone van deze server is een beter voorstel.
if naam in ("timezone", "tz") and f.get("type") != "int":
f["default"] = tz
# Hetzelfde verhaal voor de appdata-map: 266 sjablonen noemen
# /opt/serverup/appdata, ongeacht wat jij hebt ingesteld. Dan komt je
# data dus ergens anders terecht dan waar je hem verwacht.
if naam == "appdata_dir" and f.get("type") != "int":
f["default"] = appdata
# En voor PUID/PGID: 44 sjablonen zetten die blind op 1000. Draait
# Server Up onder een ander account, dan maakt de app zijn bestanden
# als een gebruiker die Server Up daarna niet meer kan inpakken voor
# een backup. Zijn eigen ids zijn het enige voorstel dat altijd klopt.
if naam == "puid":
f["default"] = os.getuid()
elif naam == "pgid":
f["default"] = os.getgid()
if f.get("type") == "int" and "port" in naam:
try:
gewenst = int(f.get("default") or 0)
except (TypeError, ValueError):
gewenst = 0
kandidaat = gewenst if gewenst > 0 else 8100
while kandidaat in bezet or kandidaat in uitgedeeld:
kandidaat += 1
uitgedeeld.add(kandidaat)
f["suggested"] = kandidaat
if kandidaat != gewenst:
f["port_taken"] = gewenst
f["default"] = kandidaat
return fields
@app.route("/api/store/preview", methods=["POST"])
def api_store_preview():
"""Preview een stack: lees compose + .env en extract configureerbare velden.
Voor boilerplate-stacks wordt de variabelen-schema uit template.json
teruggegeven zodat de install-modal een rijk formulier kan tonen."""
d = request.json or {}
stack = d.get("stack", "").strip()
rid = d.get("repo_id", "")
if not stack or not rid:
return jsonify(ok=False, msg="stack en repo_id vereist")
src = _find_stack_src(stack, rid)
if not src:
return jsonify(ok=False, msg=f"'{stack}' niet gevonden")
# ── Boilerplate (ChristianLempa-formaat) ──────────────────────────────────
if boilerplates.is_boilerplate(src):
meta = boilerplates.metadata(src)
flds = _annotate_fields(boilerplates.fields(src))
# Render met de waarden die de gebruiker tot nu toe heeft gekozen, zodat
# de laatste stap van het invulmenu laat zien wat er écht komt te staan.
# Zonder waarden (bij het openen) vallen we terug op de standaarden.
waarden = dict(d.get("values") or {})
env_preview = ""
try:
ctx = boilerplates.build_context(src, waarden)
files_dir = src / "files"
preview_files = {}
for p in sorted(files_dir.rglob("*")):
if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000:
preview_files[p.relative_to(files_dir).as_posix()] = \
boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:8000]
# Dezelfde bewerking als bij het installeren, anders toont de
# voorvertoning iets anders dan er straks op schijf komt.
sleutel = next((k for k in ("compose.yaml", "docker-compose.yml",
"compose.yml") if k in preview_files), "")
if sleutel and waarden:
nieuw, env_geheim, _ = compose_transform.geheimen_naar_env(
preview_files[sleutel], flds, waarden)
if env_geheim:
preview_files[sleutel] = nieuw
env_preview = "".join(f"{k}={v}\n" for k, v in env_geheim.items())
except Exception as e:
preview_files = {"_error": f"render: {e}"}
# Welke containers levert deze keuze op? De interface heeft die namen
# nodig om per container een eigen IP-adres aan te kunnen bieden;
# afleiden uit de veldnamen gaat mis zodra een onderdeel meer dan één
# container heeft.
diensten: list[str] = []
try:
doc = yaml.safe_load(preview_files.get("compose.yaml")
or preview_files.get("docker-compose.yml") or "") or {}
diensten = [n for n in (doc.get("services") or {})]
except Exception:
diensten = []
return jsonify(
ok=True,
stack=stack,
repo_id=rid,
format="boilerplate",
metadata=meta,
fields=flds,
services=diensten,
depends_on=_afhankelijkheden_met_status(meta),
preview_files=preview_files,
# Backwards-compat voor de oude UI:
env_vars=[], images=[], ports=[],
compose_preview=preview_files.get("compose.yaml", "") or
preview_files.get("docker-compose.yml", ""),
env_preview=env_preview,
)
# ── Legacy Server Up formaat (compose.yml + .env) ─────────────────────────
result = {"stack": stack, "repo_id": rid, "format": "compose",
"env_vars": [], "images": [], "ports": []}
# Parse .env
ef = src / ".env"
if ef.exists():
for line in ef.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
is_port = "PORT" in k.upper()
is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION")
entry = {
"key": k, "value": v,
"is_port": is_port, "is_tag": is_tag,
"secret": _is_secret_field({"name": k, "title": k}),
"desc": "Poort" if is_port else "Image tag" if is_tag else "",
}
if is_port:
try:
poort = int(v)
except ValueError:
result["ports"].append({"key": k, "value": v})
else:
vrij = docker.next_free_port(poort)
entry["suggested"] = vrij
if vrij != poort:
entry["port_taken"] = poort
entry["value"] = str(vrij)
result["ports"].append({"key": k, "value": vrij})
result["env_vars"].append(entry)
# Parse compose voor images
cf = docker.find_compose(src)
if cf:
try:
compose = yaml.safe_load(cf.read_text())
for svc_name, svc in (compose.get("services") or {}).items():
img = svc.get("image", "")
if img:
repo_part = img.split(":")[0] if ":" in img else img
tag_part = img.split(":")[-1] if ":" in img else "latest"
result["images"].append({
"service": svc_name, "image": img,
"repo": repo_part, "tag": tag_part,
})
# Also check ports in compose
for p in svc.get("ports", []):
ps = str(p)
if ":" in ps:
host_port = ps.split(":")[0].strip().strip('"').strip("'")
# Check if it's a variable reference
if "$" not in host_port:
try:
result["ports"].append({"key": f"{svc_name}_port", "value": int(host_port), "from_compose": True})
except ValueError:
pass
except Exception:
# parse error — try regex
content = cf.read_text()
for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content):
img = m.group(1)
repo_part = img.split(":")[0] if ":" in img else img
tag_part = img.split(":")[-1] if ":" in img else "latest"
if "${" not in img: # Skip variable references
result["images"].append({"service": "?", "image": img, "repo": repo_part, "tag": tag_part})
# Compose bestand inhoud voor preview
if cf:
result["compose_preview"] = cf.read_text()[:2000]
result["env_preview"] = ef.read_text()[:1000] if ef.exists() else ""
result["ok"] = True
return jsonify(result)
@app.route("/api/store/install", methods=["POST"])
def api_store_install():
d = request.json or {}
stack = d.get("stack", "").strip()
inst = d.get("instance", "").strip() or stack
rid = d.get("repo_id", "")
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
bp_values = d.get("values", {}) # boilerplate variable values
# Handmatig bewerkt in de laatste stap van het invulmenu.
compose_override = str(d.get("compose_override") or "")
env_override = str(d.get("env_override") or "")
net_name = (d.get("network") or "").strip() # leeg = poorten op de host
net_ip = (d.get("ip") or "").strip()
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
# kan bereiken (en andersom).
# Aan welke gedeelde netwerken deze stack meedoet. Eerder was dit één
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
verbinden = bool(app_netten)
if not stack:
return jsonify(ok=False, msg="stack vereist")
net = None
# Per container een eigen adres. Zonder deze mapping blijft het bij één
# adres voor de hele stack (het gewone geval: apps met één container).
net_ips: dict = {}
if net_name:
net = _network_by_name(net_name)
if not net:
return jsonify(ok=False, msg=f"Netwerk '{net_name}' is niet geconfigureerd"), 400
gevraagd = d.get("ips")
if isinstance(gevraagd, dict) and gevraagd:
net_ips, fout, code = _ip_mapping(net, net_name, gevraagd)
if fout:
return jsonify(ok=False, msg=fout), code
net_ip = ""
else:
if not net_ip:
net_ip = networks.next_free_ip(net, _reserved_ips())
if not net_ip:
return jsonify(ok=False, msg="Geen vrij IP-adres beschikbaar in dit netwerk"), 400
if not networks.ip_in_network(net_ip, net):
return jsonify(ok=False, msg=f"{net_ip} ligt buiten het subnet van '{net_name}'"), 400
if net_ip in _reserved_ips() or net_ip in networks.used_ips(net_name):
return jsonify(ok=False, msg=f"{net_ip} is al in gebruik"), 409
# 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:
return jsonify(ok=False, msg=f"'{stack}' niet gevonden")
is_bp = boilerplates.is_boilerplate(src)
if not is_bp and not docker.has_compose(src):
return jsonify(ok=False, msg=f"'{stack}' heeft geen compose bestand")
# Verplichte velden vóór het aanmaken van de job: een leeg wachtwoord of
# een lege sleutel levert een container op die niet start of die met een
# leeg geheim draait. Het formulier toonde alleen een sterretje.
if is_bp:
waarden = dict(bp_values or {})
waarden.setdefault("service_name", inst)
ontbreekt = boilerplates.ontbrekende_verplichte(src, waarden)
if ontbreekt:
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
missing=ontbreekt), 400
# Een zelf bewerkt compose-bestand eerst controleren: een typefout hoort hier
# te stranden, niet halverwege een installatie die al een map heeft gemaakt.
if compose_override.strip():
try:
yaml.safe_load(compose_override)
except yaml.YAMLError as e:
return jsonify(ok=False, msg=f"Het compose-bestand is geen geldige YAML:\n{e}"), 400
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
if lib == Path("/app").resolve():
return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.")
try:
lib.mkdir(parents=True, exist_ok=True)
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")
jid, q = jobs.create(f"install:{inst}")
def _run(jid):
qq = jobs.get_queue(jid)
secret_keys: dict = {}
velden_bron: list = []
poorten_meta: list = []
# De waarden zoals er echt mee gerenderd is, inclusief de defaults die
# de client niet meestuurde. Alleen die zijn bruikbaar om het formulier
# later opnieuw te openen.
gebruikte_waarden: dict = {}
try:
jobs.log(qq, "section", f"Installeren: {inst}")
try:
dest.mkdir(parents=True, exist_ok=True)
except PermissionError:
jobs.log(qq, "error", f"Geen schrijfrechten op '{dest}'")
jobs.finish(jid, "error"); jobs.done(qq); return
if is_bp:
# ── Boilerplate: render variabelen → compose-bestand ──────────
values = dict(bp_values or {})
# Defaults voor service_name als de gebruiker niets opgaf
values.setdefault("service_name", inst)
# Velden die de client niet meestuurde krijgen alsnog hun
# standaardwaarde, zodat het formulier later compleet is.
for _f in boilerplates.fields(src):
if _f["name"] not in values and _f.get("default") is not None:
values[_f["name"]] = _f["default"]
gebruikte_waarden = values
jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)")
# Wachtwoorden en sleutels niet uitschrijven: dit log staat in
# de interface en wordt vaak gekopieerd in een bugmelding.
velden_bron = boilerplates.fields(src)
geheim = {f["name"] for f in velden_bron if f.get("secret")}
for k, v in sorted(values.items()):
jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v)))
try:
written = boilerplates.render_to_dir(src, dest, values)
for rel in written:
jobs.log(qq, "dim", f"{rel}")
jobs.log(qq, "ok", f"Gerenderd ({len(written)} bestanden)")
except boilerplates.BoilerplateError as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error"); jobs.done(qq); return
# Tijdzone: apps zonder eigen veld draaien anders in UTC.
cf_render = docker.find_compose(dest)
if cf_render and cfg.load().get("TIMEZONE_ALL_APPS", True):
try:
cf_render.write_text(compose_transform.zorg_voor_tijdzone(
cf_render.read_text(encoding="utf-8"), cfg.tijdzone()),
encoding="utf-8")
except compose_transform.TransformError:
pass
# Wachtwoorden en sleutels naar .env, zodat het compose-bestand
# te delen is en de geheimen op één plek met 0600 staan.
if cf_render:
try:
nieuw, env_geheim, koppeling = compose_transform.geheimen_naar_env(
cf_render.read_text(encoding="utf-8"), velden_bron, values)
if env_geheim:
cf_render.write_text(nieuw, encoding="utf-8")
docker.write_env(dest, compose_transform.env_samenvoegen(
docker.read_env(dest), env_geheim))
secret_keys = koppeling
jobs.log(qq, "ok",
f"{len(env_geheim)} geheim(en) naar .env verplaatst")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
# Heb je het resultaat in de laatste stap zelf aangepast, dan
# telt jouw versie — het renderen hierboven levert dan alleen de
# overige bestanden.
if compose_override.strip() and cf_render:
cf_render.write_text(compose_override, encoding="utf-8")
jobs.log(qq, "ok", "Eigen compose-bestand geschreven")
if env_override.strip():
docker.write_env(dest, env_override)
jobs.log(qq, "ok", "Eigen .env geschreven")
else:
# ── Legacy: copy verbatim ─────────────────────────────────────
shutil.copytree(str(src), str(dest), dirs_exist_ok=True)
jobs.log(qq, "ok", f"Gekopieerd → {dest}")
# .env aanpassen met overrides
ef = dest / ".env"
if ef.exists() and env_overrides:
lines = ef.read_text().splitlines()
new_lines = []
applied = set()
for line in lines:
if "=" in line and not line.strip().startswith("#"):
k = line.split("=", 1)[0].strip()
if k in env_overrides:
new_lines.append(f"{k}={env_overrides[k]}")
applied.add(k)
jobs.log(qq, "dim", f" {k}={env_overrides[k]}")
continue
new_lines.append(line)
# Voeg nieuwe keys toe die niet in het origineel stonden
for k, v in env_overrides.items():
if k not in applied:
new_lines.append(f"{k}={v}")
jobs.log(qq, "dim", f" {k}={v} (nieuw)")
ef.write_text("\n".join(new_lines) + "\n")
jobs.log(qq, "ok", f"Env geconfigureerd ({len(env_overrides)} waarden)")
elif not ef.exists() and env_overrides:
# Maak .env aan
ef.write_text("\n".join(f"{k}={v}" for k, v in env_overrides.items()) + "\n")
jobs.log(qq, "ok", f".env aangemaakt ({len(env_overrides)} waarden)")
# Image tags aanpassen in compose
if image_tags:
cf = docker.find_compose(dest)
if cf:
content = cf.read_text()
for svc, tag in image_tags.items():
if not tag:
continue
# Vervang image:xxx tag
# Simple approach: replace all image lines
new_content = content
for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content):
img = m.group(1)
repo_part = img.split(":")[0] if ":" in img else img
# Only replace if no variable reference
if "${" not in img:
new_content = new_content.replace(img, f"{repo_part}:{tag}", 1)
jobs.log(qq, "dim", f" {repo_part}:{tag}")
break # Only first service for now
cf.write_text(new_content)
jobs.log(qq, "ok", "Image tags aangepast")
# Eigen IP-adres in plaats van poorten op de host
# Onthouden op welke poort de app zelf luistert. Bij een eigen
# IP-adres verdwijnt het ports-blok, en dan is dit de enige manier
# om er later nog naartoe te kunnen wijzen (reverse proxy, tunnel).
try:
cf_poort = docker.find_compose(dest)
if cf_poort:
poorten_meta = compose_transform.poorten_per_service(
cf_poort.read_text(encoding="utf-8"))
except Exception:
poorten_meta = []
if net:
cf = docker.find_compose(dest)
if not cf:
jobs.log(qq, "error", "Geen compose-bestand om aan te passen")
jobs.finish(jid, "error"); jobs.done(qq); return
try:
tekst = cf.read_text(encoding="utf-8")
if net_ips:
nieuw = compose_transform.to_dedicated_ips(
tekst, net_name, net_ips)
else:
nieuw = compose_transform.to_dedicated_ip(
tekst, net_name, net_ip)
except compose_transform.TransformError as e:
jobs.log(qq, "error", f"Netwerkomzetting mislukt: {e}")
jobs.finish(jid, "error"); jobs.done(qq); return
cf.write_text(nieuw, encoding="utf-8")
for svc, adres in (net_ips or {"": net_ip}).items():
jobs.log(qq, "ok", f"Eigen IP-adres{f' voor {svc}' if svc else ''}: "
f"{adres} op netwerk '{net_name}'")
jobs.log(qq, "dim", "Poortmappings van die containers verwijderd — ze "
"draaien op hun eigen poort op dat adres.")
# Aan het gedeelde netwerk hangen. Zonder dit zit elke stack in
# zijn eigen compose-netwerk en vindt geen enkele app een andere.
if app_netten:
klaar = []
for naam in app_netten:
ok_net, fout_net = networks.ensure_shared(naam)
if ok_net:
klaar.append(naam)
else:
jobs.log(qq, "warn",
f"Netwerk '{naam}' niet beschikbaar: {fout_net}"
"de stack start zonder die koppeling.")
if klaar:
cf = docker.find_compose(dest)
try:
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok",
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
+ " — apps daarin zijn bereikbaar op hun containernaam.")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
# Valideer het resultaat vóór we proberen te starten, zodat een
# fout in het template een duidelijke melding geeft.
ok_cfg, err_cfg = docker.validate_compose(dest)
if not ok_cfg:
jobs.log(qq, "error", f"Compose is ongeldig:\n{err_cfg}")
jobs.finish(jid, "error"); jobs.done(qq); return
# Data dir
try:
Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True)
except Exception:
pass
# Bewaar logo/icon-metadata zodat de stack-lijst het kan tonen
try:
meta = boilerplates.metadata(src) if is_bp else git._meta(src)
logo = meta.get("logo_url", "")
if not logo and not is_bp:
logo = git.guess_logo_url(meta.get("name") or stack)
# Geheimen gaan niet mee: die staan in .env. Hier blijft alleen
# staan wélke velden geheim waren en onder welke .env-sleutel de
# waarde te vinden is.
zichtbaar, geheime_namen = _splits_geheimen(
velden_bron if is_bp else [], gebruikte_waarden if is_bp else {})
_stack_meta_schrijven(dest, {
"source": stack,
"repo_id": rid,
"name": meta.get("name", stack),
"logo_url": logo,
"icon": meta.get("icon", ""),
# Reserveert het adres: zolang deze stack bestaat krijgt
# geen andere installatie hetzelfde IP voorgesteld.
"network": net_name,
"ip": net_ip if net else "",
"ips": net_ips if net else {},
"connected": bool(app_netten),
"app_networks": app_netten,
"service_ports": poorten_meta,
"categories": meta.get("categories") or [],
"tags": meta.get("tags") or [],
# Zonder de gebruikte waarden valt het installatieformulier
# later niet opnieuw te openen om iets te wijzigen.
"format": "boilerplate" if is_bp else "compose",
"values": zichtbaar if is_bp else {},
"secret_fields": geheime_namen,
"secret_keys": secret_keys,
"env_overrides": {} if is_bp else env_overrides,
})
except Exception:
pass
# Start
jobs.log(qq, "section", "Starten")
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst)
jobs.log(qq, "ok" if rc == 0 else "warn",
"Gestart" if rc == 0 else "Start mislukt")
audit.log("store", "install", "ok", ref=inst,
detail={"env": list(env_overrides.keys()), "tags": image_tags})
jobs.finish(jid, "done")
except PermissionError as e:
jobs.log(qq, "error", f"Geen schrijfrechten: {e}")
jobs.finish(jid, "error")
except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Updates van geïnstalleerde stacks ────────────────────────────────────────
@app.route("/api/stacks/updates")
def api_stack_updates():
"""Bekende updatestatus per stack. `?force=1` doet een verse check."""
force = request.args.get("force") in ("1", "true", "yes")
if force:
return jsonify(ok=True, stacks=stackupdates.check_all(force=True))
return jsonify(ok=True, stacks=stackupdates.cached())
@app.route("/api/stacks/updates/check", methods=["POST"])
def api_stack_updates_check():
"""Verse check als job, want dit doet een netwerkverzoek per image."""
jid, q = jobs.create("stack-updates")
def _run(jid):
qq = jobs.get_queue(jid)
jobs.log(qq, "section", "Updates controleren")
res = stackupdates.check_all(force=True,
log_fn=lambda m: jobs.log(qq, "dim", m))
met_update = [n for n, r in res.items() if r.get("update_available")]
jobs.log(qq, "ok", f"{len(met_update)} van {len(res)} stacks heeft een update"
+ (f": {', '.join(met_update)}" if met_update else ""))
jobs.finish(jid, "done")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Backups ───────────────────────────────────────────────────────────────────
@app.route("/api/backups")
def api_backups():
stack = request.args.get("stack", "").strip()
lijst = backups.list_for(stack) if stack else backups.list_all()
c = cfg.load()
return jsonify(backups=lijst, disk=diskspace.status(), settings={
k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS",
"BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR",
"BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE",
"BACKUP_VERIFY", "BACKUP_OFFSITE_DIR")})
@app.route("/api/backups/<stack>/create", methods=["POST"])
def api_backup_create(stack):
jid, q = jobs.create(f"backup:{stack}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Backup: {stack}")
meta = backups.create(stack, reden="handmatig",
log_fn=lambda m: jobs.log(qq, "dim", m))
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")
except backups.BackupError as e:
jobs.log(qq, "error", str(e))
audit.log("backups", "create", "error", ref=stack, detail=str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/backups/<stack>/restore", methods=["POST"])
def api_backup_restore(stack):
bestand = ((request.json or {}).get("file") or "").strip()
if not bestand:
return jsonify(ok=False, msg="Geen backup opgegeven"), 400
try:
backups._archive_path(bestand) # vroege validatie
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
jid, q = jobs.create(f"restore:{stack}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Terugzetten: {stack}")
ok, msg = backups.restore(stack, bestand,
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("backups", "restore", "ok" if ok else "error", ref=stack,
detail=bestand)
jobs.finish(jid, "done" if ok else "error")
except backups.BackupError as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/backups/<path:bestand>/download")
def api_backup_download(bestand):
"""Het archief downloaden.
Zonder dit staan backups uitsluitend op de machine die ze moet beschermen.
`_archive_path` valideert de naam en houdt het pad binnen BACKUP_DIR.
"""
try:
pad = backups._archive_path(bestand)
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
if not pad.is_file():
return jsonify(ok=False, msg="Backup niet gevonden."), 404
audit.log("backups", "download", "ok", ref=bestand, ip=request.remote_addr)
return send_file(pad, as_attachment=True, download_name=pad.name,
mimetype="application/gzip")
@app.route("/api/backups/<path:bestand>/verify", methods=["POST"])
def api_backup_verify(bestand):
diep = bool((request.json or {}).get("deep"))
jid, q = jobs.create(f"verify:{bestand}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Controleren: {bestand}")
ok, msg = backups.verify(bestand, diep=diep,
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("backups", "verify", "ok" if ok else "error", ref=bestand,
detail=msg[:200])
if not ok:
notify.send("backup_verify", "Backup niet te verifiëren",
f"{bestand}: {msg}")
jobs.finish(jid, "done" if ok else "error")
except backups.BackupError as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/backups/<path:bestand>/offsite", methods=["POST"])
def api_backup_offsite(bestand):
"""Een bestaande backup alsnog naar de externe bestemming kopiëren."""
try:
ok, msg = backups.kopieer_extern(bestand)
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
audit.log("backups", "offsite", "ok" if ok else "error", ref=bestand,
detail=msg[:200], ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/backups/<path:bestand>", methods=["DELETE"])
def api_backup_delete(bestand):
try:
ok, msg = backups.delete(bestand)
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
if ok:
audit.log("backups", "delete", "ok", ref=bestand, ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 404)
# ── Meldingen en schijfruimte ────────────────────────────────────────────────
@app.route("/api/notify")
def api_notify():
"""Instellingen plus de lijst gebeurtenissen waarover gemeld kan worden.
Het token en het SMTP-wachtwoord komen hier bewust niet in terug; die zijn
write-only, net als de git- en registry-tokens.
"""
i = notify.instellingen()
c = cfg.load()
return jsonify(
channel=i["channel"], url=i["url"], events=i["events"],
email={k: i[f"email_{k}"] for k in ("host", "port", "user", "from",
"to", "security")},
has_token=bool(i["token"]),
has_email_password=bool(i["email_password"]),
available={k: v for k, v in notify.EVENTS.items()},
channels=[c for c in notify.KANALEN if c],
enabled=bool(i["channel"]),
stack_check=bool(c.get("STACK_DOWN_CHECK", True)),
)
@app.route("/api/notify/test", methods=["POST"])
def api_notify_test():
"""Stuur een testbericht via het ingestelde kanaal.
Zonder dit merk je pas dat de instellingen fout zijn op het moment dat er
echt iets misgaat — precies wanneer je de melding nodig had.
"""
ok, msg = notify.verstuur(
"Testbericht van Server Up",
"Als je dit ziet, komen meldingen aan.\n\n"
"Verstuurd vanaf de knop 'Test versturen' in de instellingen.",
prioriteit="normaal")
audit.log("notify", "test", "ok" if ok else "error", detail=msg[:200],
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/disk")
def api_disk():
return jsonify(diskspace.status())
@app.route("/api/pangolin")
def api_pangolin():
"""Instellingen plus, als het werkt, de sites en domeinen om uit te kiezen."""
i = pangolin.instellingen()
uit = {"url": i["url"], "org": i["org"], "site": i["site"],
"domain": i["domain"], "has_key": bool(i["key"]),
"configured": pangolin.ingesteld(), "sites": [], "domains": [],
"error": ""}
if uit["configured"]:
try:
uit["sites"] = pangolin.sites()
uit["domains"] = pangolin.domeinen()
except pangolin.PangolinError as e:
uit["error"] = str(e)
return jsonify(uit)
@app.route("/api/pangolin/test", methods=["POST"])
def api_pangolin_test():
ok, msg = pangolin.test()
audit.log("pangolin", "test", "ok" if ok else "error", detail=msg[:200],
ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 400)
@app.route("/api/stacks/<name>/pangolin", methods=["POST"])
def api_stack_pangolin(name):
"""Maak een publieke URL voor deze stack aan in Pangolin."""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
if not pangolin.ingesteld():
return jsonify(ok=False, msg="Pangolin is nog niet ingesteld."), 400
body = request.json or {}
meta = _stack_meta_lezen(d)
# Welke container publiceren we? Een stack als de ARR-stack heeft er twintig
# met een eigen webinterface; zonder deze keuze kreeg altijd de eerste poort
# in het bestand de URL.
service = (body.get("service") or "").strip()
# Waar wijst de URL naartoe? Een eigen IP-adres, anders de host op de
# gepubliceerde poort.
ip = (body.get("ip") or (meta.get("ips") or {}).get(service)
or meta.get("ip") or "").strip()
poort = body.get("port")
if not poort and service:
# Heeft de container een eigen adres, dan luistert hij op zijn interne
# poort; anders is de gepubliceerde poort op de host het doel.
for p in meta.get("service_ports") or []:
if p.get("service") == service:
poort = p.get("container") if ip else (p.get("host") or p.get("container"))
break
if not poort:
try:
poorten = compose_transform.published_ports(docker.read_compose(d))
eerste = str(poorten[0]).strip('"').replace("/tcp", "").split(":")
poort = int(eerste[-2] if len(eerste) >= 2 else eerste[0])
except Exception:
poort = 0
if not ip:
ip = (body.get("host_ip") or "").strip() or _eigen_host_ip()
if not ip or not poort:
return jsonify(ok=False,
msg="Kan niet bepalen waar "
f"{service or 'deze stack'} draait; vul het "
"adres en de poort zelf in."), 400
standaard_naam = service or name
try:
res = pangolin.publiceer(
naam=body.get("name") or standaard_naam,
subdomein=(body.get("subdomain") or standaard_naam).strip().lower(),
ip=ip, poort=int(poort),
site_id=body.get("site_id"), domein_id=body.get("domain_id", ""))
except pangolin.PangolinError as e:
audit.log("pangolin", "publish", "error", ref=name, detail=str(e)[:200])
return jsonify(ok=False, msg=str(e)), 400
# Per container onthouden, zodat Sonarr, Radarr en Prowlarr elk hun eigen
# adres kunnen hebben. Zonder `service` blijft het één URL voor de stack.
urls = dict(meta.get("pangolin_urls") or {})
urls[service or name] = res.get("url", "")
updates = {"pangolin_urls": urls,
"pangolin_resource": res.get("resource_id")}
if not service:
updates["pangolin_url"] = res.get("url", "")
_stack_meta_schrijven(d, updates)
audit.log("pangolin", "publish", "ok", ref=name,
detail=res.get("url", "")[:200], ip=request.remote_addr)
return jsonify(ok=True, **res)
def _eigen_host_ip() -> str:
"""Adres waarop de host bereikbaar is, voor het doel van een tunnel."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return ""
@app.route("/api/appnetworks")
def api_app_networks():
"""Gedeelde netwerken waar apps aan gekoppeld kunnen worden."""
return jsonify(networks=networks.app_netwerken(),
suggested=networks.voorstel_netwerknaam(
request.args.get("for", "")))
@app.route("/api/appnetworks", methods=["POST"])
def api_app_network_create():
"""Maak een gedeeld netwerk aan.
Zonder naam verzinnen we er een, zodat je niet hoeft na te denken over iets
wat toch alleen intern gebruikt wordt.
"""
d = request.json or {}
naam = (d.get("name") or "").strip()
if not naam:
naam = networks.voorstel_netwerknaam(d.get("for", ""))
ok, fout = networks.app_netwerk_toevoegen(naam)
audit.log("networks", "app_create", "ok" if ok else "error", ref=naam,
detail=fout[:200], ip=request.remote_addr)
if not ok:
return jsonify(ok=False, msg=fout), 400
return jsonify(ok=True, name=naam, networks=networks.app_netwerken())
@app.route("/api/appnetworks/<name>", methods=["DELETE"])
def api_app_network_delete(name):
ok, fout = networks.app_netwerk_verwijderen(name)
audit.log("networks", "app_delete", "ok" if ok else "error", ref=name,
detail=fout[:200], ip=request.remote_addr)
return jsonify(ok=ok, msg=fout), (200 if ok else 400)
@app.route("/api/ports/check")
def api_port_check():
"""Is deze poort vrij, en zo nee: welke wel?
Kijkt naar wat Docker publiceert én naar wat er rechtstreeks op de host
luistert; dat laatste zag Server Up eerder niet.
"""
try:
poort = int(request.args.get("port") or 0)
except ValueError:
poort = 0
if not 1 <= poort <= 65535:
return jsonify(ok=False, msg="Ongeldige poort"), 400
bezet = _bezette_poorten()
door = ""
if poort in bezet:
try:
door = next((c["name"] for c in docker.list_containers(alle=False)
if f":{poort}->" in (c.get("ports") or "")), "")
except Exception:
door = ""
vrij = poort not in bezet
voorstel = poort
while voorstel in bezet:
voorstel += 1
return jsonify(ok=True, port=poort, free=vrij, suggested=voorstel,
used_by=door,
# Zonder de hulpcontainer zien we alleen Docker; dan is
# "vrij" een aanname en dat moet de melding zeggen.
host_checked=bool(networks.host_luisterpoorten()))
@app.route("/api/networks/<name>/ip-check")
def api_ip_check(name):
"""Is dit adres bruikbaar binnen dit netwerk?
Drie bronnen: het subnet en bereik uit de instellingen, de adressen die
Docker al heeft uitgedeeld, en wat de host recent op het LAN heeft gezien.
Dat laatste vangt een fysiek apparaat met een DHCP-adres — precies de
botsing die pas weken later opvalt.
"""
net = _network_by_name(name)
if not net:
return jsonify(ok=False, msg=f"Netwerk '{name}' bestaat niet"), 404
ip = (request.args.get("ip") or "").strip()
if not ip:
vrij = networks.next_free_ip(net, _reserved_ips())
return jsonify(ok=True, ip="", suggested=vrij,
msg="" if vrij else "Geen vrij adres gevonden in dit bereik")
if not networks.ip_in_network(ip, net):
return jsonify(ok=True, ip=ip, free=False, in_subnet=False,
suggested=networks.next_free_ip(net, _reserved_ips()),
reason=f"{ip} valt buiten het subnet {net.get('subnet')}")
redenen = []
try:
if ip in networks.used_ips(name):
redenen.append("al toegekend aan een container in dit netwerk")
except Exception:
pass
if ip in _reserved_ips():
redenen.append("gereserveerd door een andere stack in Server Up")
if ip == (net.get("gateway") or "").strip():
redenen.append("dit is het adres van de gateway")
try:
if ip in networks.buren_op_het_lan():
redenen.append("een apparaat op je netwerk gebruikt dit adres")
except Exception:
pass
bereik = net.get("ip_range") or ""
buiten_bereik = bool(bereik) and not networks.ip_in_network(
ip, {"subnet": bereik})
if buiten_bereik:
redenen.append(f"valt buiten het ingestelde bereik {bereik}")
return jsonify(ok=True, ip=ip, free=not redenen, in_subnet=True,
reason="; ".join(redenen),
suggested="" if not redenen
else networks.next_free_ip(net, _reserved_ips()))
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
def _network_by_name(name: str) -> dict | None:
for n in cfg.load().get("NETWORKS", []) or []:
if isinstance(n, dict) and n.get("name") == name:
return n
return None
def _reserved_ips(exclude_stack: str = "") -> set[str]:
"""IP's die Server Up bij installatie aan een stack heeft toegekend.
Staat los van used_ips(): een gestopte stack houdt zijn adres, zodat een
nieuwe installatie het niet afpakt.
"""
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
ips: set[str] = set()
if not lib.exists():
return ips
for d in lib.iterdir():
if not d.is_dir() or d.name == exclude_stack:
continue
mf = d / ".serverup.json"
if not mf.exists():
continue
try:
meta = json.loads(mf.read_text("utf-8"))
except Exception:
continue
if meta.get("ip"):
ips.add(meta["ip"])
return ips
@app.route("/api/networks")
def api_networks():
defined = cfg.load().get("NETWORKS", []) or []
live = {n["name"]: n for n in networks.list_docker_networks()}
out = []
for n in defined:
if not isinstance(n, dict):
continue
naam = n.get("name", "")
entry = {**n, "created": naam in live,
"used_ips": sorted(networks.used_ips(naam)) if naam in live else []}
entry["next_ip"] = networks.next_free_ip(n, _reserved_ips())
out.append(entry)
gedetecteerd = networks.detect_host_networks()
# Gedetecteerde netwerken blijven altijd staan, ook als er al een netwerk op
# dat subnet bestaat: je kunt er prima een tweede aanmaken met een andere
# range, en de gegevens zijn sowieso handig om te kunnen naslaan. Ze worden
# alleen gemarkeerd als 'al toegevoegd'.
bestaand = {n.get("subnet") for n in defined if isinstance(n, dict)}
voorstellen = [{**n, "configured": n["subnet"] in bestaand}
for n in gedetecteerd["networks"]]
return jsonify(networks=out,
detected=voorstellen,
detect_error=gedetecteerd.get("error", ""),
interfaces=[n["interface"] for n in gedetecteerd["networks"]],
drivers=list(networks.DRIVERS))
@app.route("/api/categories")
def api_categories():
"""Categoriedefinities met hun kleur, zodat de interface overal dezelfde
kleur gebruikt voor dezelfde categorie."""
lang = (request.args.get("lang") or cfg.load().get("LANGUAGE") or "nl")[:5]
return jsonify(categories=categories.all_categories(lang))
@app.route("/api/apps/connectable")
def api_connectable():
"""Ge<0x00ef>nstalleerde apps waar een nieuwe app aan gekoppeld kan worden.
Per stack de containernamen zoals ze binnen het gedeelde netwerk op te
lossen zijn, plus uit welke app-template hij komt. Daarmee kan de
installatiemodal een keuzelijst tonen in plaats van de gebruiker zelf een
adres te laten typen.
"""
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
uit = []
if lib.is_dir():
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
meta = {}
mf = d / ".serverup.json"
if mf.exists():
try:
meta = json.loads(mf.read_text("utf-8"))
except Exception:
meta = {}
containers = [{"name": c["name"], "service": c.get("service", ""),
"ports": c.get("ports", "")}
for c in docker.compose_ps(d, name=d.name) if c.get("name")]
uit.append({
"stack": d.name,
"source": meta.get("source", ""), # uit welke app-template
"name": meta.get("name") or d.name,
"ip": meta.get("ip", ""),
"containers": containers or [{"name": d.name, "service": "", "ports": ""}],
})
c = cfg.load()
return jsonify(apps=uit, shared_network=c.get("SHARED_NETWORK", "serverup"),
connect_default=bool(c.get("CONNECT_APPS_DEFAULT", True)))
@app.route("/api/networks/range", methods=["POST"])
def api_networks_range():
"""Zet "van adres X tot adres Y" om in bruikbare CIDR-blokken."""
d = request.json or {}
return jsonify(networks.range_options(
d.get("first", ""), d.get("last", ""), d.get("subnet", "")))
@app.route("/api/networks/review", methods=["POST"])
def api_networks_review():
"""Beoordeel een definitie zonder iets aan te maken (live in het formulier)."""
d = request.json or {}
return jsonify(networks.review({
"name": (d.get("name") or "").strip(),
"driver": d.get("driver") or "macvlan",
"parent": (d.get("parent") or "").strip(),
"subnet": (d.get("subnet") or "").strip(),
"gateway": (d.get("gateway") or "").strip(),
"ip_range": (d.get("ip_range") or "").strip(),
}))
@app.route("/api/networks", methods=["POST"])
def api_networks_add():
d = request.json or {}
# Normaliseren vóór opslaan: wat we bewaren moet gelijk zijn aan wat Docker
# heeft gekregen, anders wijkt de config af van het echte netwerk.
net = networks.normalize({
"name": (d.get("name") or "").strip(),
"driver": d.get("driver") or "macvlan",
"parent": (d.get("parent") or "").strip(),
"subnet": (d.get("subnet") or "").strip(),
"gateway": (d.get("gateway") or "").strip(),
"ip_range": (d.get("ip_range") or "").strip()})
probleem = networks.validate(net)
if probleem:
return jsonify(ok=False, msg=probleem), 400
if _network_by_name(net["name"]):
return jsonify(ok=False, msg="Er bestaat al een netwerk met deze naam"), 409
ok, msg = networks.create(net)
if not ok:
return jsonify(ok=False, msg=msg), 400
cfg.patch({"NETWORKS": list(cfg.load().get("NETWORKS", []) or []) + [net]})
audit.log("networks", "add", "ok", ref=net["name"], ip=request.remote_addr)
return jsonify(ok=True, msg=msg)
@app.route("/api/networks/<name>", methods=["DELETE"])
def api_networks_del(name):
net = _network_by_name(name)
if not net:
return jsonify(ok=False, msg="Netwerk niet gevonden"), 404
in_gebruik = sorted(networks.used_ips(name))
if in_gebruik:
return jsonify(ok=False,
msg=f"Nog {len(in_gebruik)} container(s) op dit netwerk — "
"stop die stacks eerst."), 409
ok, msg = networks.remove(name)
cfg.patch({"NETWORKS": [n for n in cfg.load().get("NETWORKS", []) or []
if n.get("name") != name]})
audit.log("networks", "delete", "ok" if ok else "error", ref=name,
ip=request.remote_addr)
return jsonify(ok=True, msg=msg if ok else f"Uit config verwijderd ({msg})")
# ── Module Store ──────────────────────────────────────────────────────────────
@app.route("/api/modules")
def api_modules():
active = cfg.load().get("ACTIVE_MODULES")
out = []
for m in MODULES:
en = active is None or m.ID in (active or [])
info = {**m.info(), "enabled": en}
js = _mod_js(m)
if js:
info["js"] = f"/api/modules/{m.ID}/js"
out.append(info)
return jsonify(out)
@app.route("/api/modules/<mid>/toggle", methods=["POST"])
def api_mod_toggle(mid):
en = (request.json or {}).get("enabled", False)
c = cfg.load()
active = list(c.get("ACTIVE_MODULES") or [m.ID for m in MODULES])
if en and mid not in active:
active.append(mid)
elif not en and mid in active:
active.remove(mid)
cfg.patch({"ACTIVE_MODULES": active})
return jsonify(ok=True)
@app.route("/api/modules/<mid>/js")
def api_mod_js(mid):
for m in MODULES:
if m.ID == mid:
js = _mod_js(m)
if js:
return Response(js.read_text(), mimetype="application/javascript")
return Response("", 404, mimetype="application/javascript")
@app.route("/api/modules/store")
def api_mod_store():
c = cfg.load()
installed = {}
if USER_MOD.exists():
for d in USER_MOD.iterdir():
if d.is_dir() and (d / "__init__.py").exists() and (d / "module.json").exists():
mid = d.name
if mid in CORE:
continue
meta = {}
try:
meta = json.loads((d / "module.json").read_text())
except Exception:
pass
installed[mid] = meta.get("version", "0.0.0")
available = {}
for repo in c.get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] not in CORE and mod["id"] not in available:
available[mod["id"]] = mod
result = []
for mid, mod in available.items():
is_installed = mid in installed
mod["installed"] = is_installed
if is_installed:
local_ver = installed[mid]
repo_ver = mod.get("version", "0.0.0")
mod["installed_version"] = local_ver
mod["repo_version"] = repo_ver
if _ver_newer(repo_ver, local_ver):
mod["status"] = "update"
else:
mod["status"] = "installed"
else:
mod["status"] = "available"
result.append(mod)
for mid, ver in installed.items():
if mid not in available and mid not in CORE:
result.append({"id": mid, "name": mid, "icon": "🧩", "version": ver,
"installed_version": ver,
"desc": "", "installed": True, "status": "orphan"})
return jsonify(modules=sorted(result, key=lambda m: m.get("name", "")))
def _ver_newer(repo_ver: str, local_ver: str) -> bool:
"""Vergelijk semver strings: True als repo_ver nieuwer is dan local_ver."""
def _parse(v):
try:
return tuple(int(x) for x in v.replace("v", "").split(".")[:3])
except (ValueError, AttributeError):
return (0, 0, 0)
return _parse(repo_ver) > _parse(local_ver)
@app.route("/api/modules/install", methods=["POST"])
def api_mod_install():
mid = (request.json or {}).get("id", "").strip()
if not mid or mid in CORE:
return jsonify(ok=False, msg="niet mogelijk")
src = None
for repo in cfg.load().get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] == mid:
src = Path(mod["_src"])
break
if src:
break
if not src:
return jsonify(ok=False, msg="niet gevonden in repo")
# Valideer: heeft module.json?
if not (src / "module.json").exists():
return jsonify(ok=False, msg=f"'{mid}' heeft geen module.json — geen geldige module")
# Valideer: heeft Module subclass?
from core.modules import _load_detail
cls, load_error = _load_detail(mid, src)
if not cls:
# Toon de __init__.py inhoud voor debug
init_content = ""
try:
init_content = (src / "__init__.py").read_text()[:500]
except Exception:
init_content = "(kon niet lezen)"
return jsonify(ok=False,
msg=f"'{mid}' kon niet geladen worden: {load_error}",
debug={"src": str(src),
"init_preview": init_content,
"files": [f.name for f in src.iterdir()][:20]})
dest = USER_MOD / mid
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(str(src), str(dest))
active = cfg.load().get("ACTIVE_MODULES")
if active is not None:
active = list(active)
if mid not in active:
active.append(mid)
cfg.patch({"ACTIVE_MODULES": active})
audit.log("modules", "install", "ok", ref=mid)
ok, msg = _notify_restart(mid)
return jsonify(ok=True, hot=ok, msg=msg)
@app.route("/api/modules/uninstall", methods=["POST"])
def api_mod_uninstall():
mid = (request.json or {}).get("id", "").strip()
if mid in CORE:
return jsonify(ok=False, msg="core kan niet verwijderd worden")
dest = USER_MOD / mid
if not dest.exists():
return jsonify(ok=False, msg="niet gevonden")
shutil.rmtree(dest)
active = cfg.load().get("ACTIVE_MODULES")
if active is not None:
cfg.patch({"ACTIVE_MODULES": [m for m in active if m != mid]})
audit.log("modules", "uninstall", "ok", ref=mid)
return jsonify(ok=True, msg="verwijderd — herstart vereist")
@app.route("/api/modules/update", methods=["POST"])
def api_mod_update():
"""Update een module naar de nieuwste versie uit de repo. Behoudt settings."""
mid = (request.json or {}).get("id", "").strip()
if not mid or mid in CORE:
return jsonify(ok=False, msg="niet mogelijk")
# Zoek nieuwste versie in repo
src = None
for repo in cfg.load().get("MODULE_REPOS", []):
for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")):
if mod["id"] == mid:
src = Path(mod["_src"])
break
if src:
break
if not src:
return jsonify(ok=False, msg="niet gevonden in repo")
# Valideer
from core.modules import _load_detail
cls, err = _load_detail(mid, src)
if not cls:
return jsonify(ok=False, msg=f"update fout: {err}")
# Lees oude + nieuwe versie
dest = USER_MOD / mid
old_ver = "?"
if dest.exists():
try:
old_meta = json.loads((dest / "module.json").read_text())
old_ver = old_meta.get("version", "?")
except Exception:
pass
new_ver = "?"
try:
new_meta = json.loads((src / "module.json").read_text())
new_ver = new_meta.get("version", "?")
except Exception:
pass
# Overschrijf bestanden (settings in MODULE_SETTINGS blijven bewaard)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(str(src), str(dest))
audit.log("modules", "update", "ok", ref=f"{mid} {old_ver}{new_ver}")
return jsonify(ok=True, msg=f"Bijgewerkt {old_ver}{new_ver} — herstart vereist",
old_version=old_ver, new_version=new_ver)
# ── Module repos ──────────────────────────────────────────────────────────────
@app.route("/api/modrepos")
def api_modrepos():
repos = cfg.redact(cfg.load()).get("MODULE_REPOS", [])
return jsonify([{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos])
@app.route("/api/modrepos", methods=["POST"])
def api_modrepos_add():
d = request.json or {}
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]
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "") or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"),
"subdir": d.get("subdir", "")})
cfg.patch({"MODULE_REPOS": repos})
return jsonify(ok=True)
@app.route("/api/modrepos/<rid>", methods=["DELETE"])
def api_modrepos_del(rid):
repos = [r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] != rid]
cfg.patch({"MODULE_REPOS": repos})
c = git.cache_dir(rid)
if c.exists():
shutil.rmtree(c)
return jsonify(ok=True)
@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:
return jsonify(ok=False, msg="niet gevonden")
jid, q = jobs.create(f"modsync:{rid}")
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Settings ──────────────────────────────────────────────────────────────────
_SETTINGS_HIDE = {"WIZARD_DONE", "ACTIVE_MODULES"}
@app.route("/api/settings")
def api_settings():
c = cfg.redact(cfg.load())
uit = {k: v for k, v in c.items() if k not in _SETTINGS_HIDE}
# Wat de server zelf gebruikt als er geen tijdzone is ingesteld; de
# interface toont dat als placeholder.
uit["host_timezone"] = cfg.host_timezone()
return jsonify(uit)
@app.route("/api/settings", methods=["PUT"])
def api_settings_put():
d = request.json or {}
# 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()
# Write-only credentials: leeg laten staan betekent "niet wijzigen", want de
# UI kent de huidige waarde niet (zie cfg.redact).
for key in cfg.WRITE_ONLY_KEYS:
updates.pop(f"has_{key.lower()}", None)
if key in updates and not str(updates[key] or "").strip():
updates.pop(key)
if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS:
return jsonify(ok=False, msg="Onbekend update-kanaal"), 400
if "TIMEZONE" in updates:
tz = str(updates["TIMEZONE"] or "").strip()
if tz and not cfg.geldige_tijdzone(tz):
return jsonify(ok=False,
msg="Ongeldige tijdzone; verwacht iets als Europe/Amsterdam"), 400
updates["TIMEZONE"] = tz
if "NOTIFY_CHANNEL" in updates:
if (updates.get("NOTIFY_CHANNEL") or "") not in notify.KANALEN:
return jsonify(ok=False, msg="Onbekend meldingskanaal"), 400
# Kan Server Up bij de mappen die je opgeeft? De container ziet alleen wat
# via BASE_DIR gekoppeld is. Een pad daarbuiten laat zich hier prima
# opslaan, en daarna kijkt de app in een map die binnen de container niet
# bestaat: lege appslijst, alle containers onder "handmatig gestart", en
# nergens een melding.
#
# Deze controle bestond al voor de hoofdmap en voor het verhuizen, maar de
# drie velden schrijven rechtstreeks hierheen — dus liep het langs de wacht.
for sleutel in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
if sleutel not in updates:
continue
nieuw_pad = str(updates[sleutel] or "").strip().rstrip("/")
if not nieuw_pad or nieuw_pad == (current.get(sleutel) or "").rstrip("/"):
continue
controle = paden.bereikbaar(nieuw_pad)
if not controle.get("ok"):
return jsonify(**{**controle, "veld": sleutel,
"msg": _padfout_tekst(controle)}), 400
updates[sleutel] = nieuw_pad
if "NOTIFY_EVENTS" in updates:
ev = updates["NOTIFY_EVENTS"]
if not isinstance(ev, list) or any(e not in notify.EVENTS for e in ev):
return jsonify(ok=False, msg="Onbekende gebeurtenis in NOTIFY_EVENTS"), 400
if (updates.get("NOTIFY_EMAIL_SECURITY") or "starttls") not in ("starttls", "ssl", "geen"):
return jsonify(ok=False, msg="Onbekende SMTP-beveiliging"), 400
for sleutel, ondergrens, bovengrens in (("DISK_MIN_FREE_GB", 0, 1024),
("DISK_WARN_PCT", 0, 99),
("NOTIFY_EMAIL_PORT", 1, 65535)):
if sleutel not in updates:
continue
try:
waarde = float(updates[sleutel])
except (TypeError, ValueError):
return jsonify(ok=False, msg=f"{sleutel} moet een getal zijn"), 400
if not ondergrens <= waarde <= bovengrens:
return jsonify(ok=False,
msg=f"{sleutel} moet tussen {ondergrens} en {bovengrens} liggen"), 400
if "BACKUP_OFFSITE_DIR" in updates:
pad = str(updates["BACKUP_OFFSITE_DIR"] or "").strip()
# Een relatief pad zou zich richten naar de werkmap van het proces; dat
# is niet waar iemand zijn backups verwacht.
if pad and not pad.startswith("/"):
return jsonify(ok=False,
msg="De externe backupmap moet een absoluut pad zijn"), 400
updates["BACKUP_OFFSITE_DIR"] = pad
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)
@app.route("/api/settings/panels")
def api_settings_panels():
panels = []
for m in MODULES:
h = m.settings_html()
mc = cfg.load().get("MODULE_SETTINGS", {}).get(m.ID, {})
# Lees settings schema uit module.json
schema = {}
src = getattr(m, "_src", None)
if src:
mf = Path(src) / "module.json"
if mf.exists():
try:
meta = json.loads(mf.read_text())
schema = meta.get("settings", {})
except Exception:
pass
panels.append({
"id": m.ID, "name": m.NAME or m.ID, "icon": m.ICON,
"has_html": bool(h and h.strip()), "html": h or "",
"config": mc, "schema": schema,
})
return jsonify(panels=panels)
@app.route("/api/modules/<mid>/settings")
def api_mod_settings_get(mid):
mc = cfg.load().get("MODULE_SETTINGS", {}).get(mid, {})
return jsonify(config=mc)
@app.route("/api/modules/<mid>/settings", methods=["PUT", "POST"])
def api_mod_settings_put(mid):
d = request.json or {}
c = cfg.load()
ms = dict(c.get("MODULE_SETTINGS", {}))
ms[mid] = d
cfg.patch({"MODULE_SETTINGS": ms})
audit.log("modules", "settings", "ok", ref=mid)
return jsonify(ok=True)
# ── Audit ─────────────────────────────────────────────────────────────────────
@app.route("/api/audit")
def api_audit():
"""Auditregels, optioneel gefilterd en doorbladerbaar."""
a = request.args
filters = {
"src": (a.get("src") or "").strip(),
"action": (a.get("action") or "").strip(),
"status": (a.get("status") or "").strip(),
"zoek": (a.get("q") or "").strip()[:100],
}
dagen = a.get("days", "").strip()
if dagen.isdigit() and int(dagen) > 0:
filters["sinds"] = time.time() - int(dagen) * 86400
limit = min(max(int(a.get("limit", 100)), 1), 500)
offset = max(int(a.get("offset", 0)), 0)
return jsonify(entries=audit.query(limit, offset, **filters),
total=audit.count(**filters),
grand_total=audit.count(),
facets=audit.facets(),
limit=limit, offset=offset)
@app.route("/api/audit/clear", methods=["POST"])
def api_audit_clear():
audit.clear()
audit.log("audit", "clear", "ok")
return jsonify(ok=True)
# ── i18n ──────────────────────────────────────────────────────────────────────
@app.route("/api/i18n")
def api_i18n():
return jsonify(languages=i18n.available())
@app.route("/api/i18n/<lang>")
def api_i18n_lang(lang):
return jsonify(strings=i18n.strings(lang))
@app.route("/api/i18n/keys")
def api_i18n_keys():
"""Referentie: alle vertaalsleutels + Engelse waardes (voor de editor)."""
return jsonify(keys=i18n.reference_keys(), en=i18n.strings("en"))
@app.route("/api/i18n/<lang>/raw")
def api_i18n_lang_raw(lang):
"""Alleen de eigen strings van deze taal (voor bewerken)."""
return jsonify(strings=i18n.raw(lang), builtin=i18n.is_builtin(lang))
@app.route("/api/i18n", methods=["POST"])
def api_i18n_save():
"""Voeg een taal toe of werk er een bij (add-on)."""
body = request.get_json(silent=True) or {}
res = i18n.save(
code=body.get("code", ""),
name=body.get("name", ""),
flag=body.get("flag", ""),
entries=body.get("strings", {}),
)
if res.get("ok"):
audit.log("i18n", "save", "ok", ref=res.get("code"))
return jsonify(res), (200 if res.get("ok") else 400)
@app.route("/api/i18n/<lang>", methods=["DELETE"])
def api_i18n_delete(lang):
res = i18n.delete(lang)
if res.get("ok"):
audit.log("i18n", "delete", "ok", ref=lang)
return jsonify(res), (200 if res.get("ok") else 400)
# ── Wizard ────────────────────────────────────────────────────────────────────
@app.route("/api/wizard")
def api_wizard():
c = cfg.load()
return jsonify(done=bool(c.get("WIZARD_DONE")),
defaults={k: c.get(k) for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR",
"APP_REPOS", "MODULE_REPOS")})
@app.route("/api/wizard/paths", methods=["POST"])
def api_wizard_paths():
try:
d = request.json or {}
updates = {}
errors = []
for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
if k in d and d[k]:
updates[k] = d[k]
try:
Path(d[k]).mkdir(parents=True, exist_ok=True)
except Exception as e:
errors.append(f"{d[k]}: {e}")
if updates:
cfg.patch(updates)
return jsonify(ok=True, errors=errors)
except Exception as e:
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/repos", methods=["POST"])
def api_wizard_repos():
"""Sla repos op vanuit de wizard."""
try:
d = request.json or {}
updates = {}
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",
detail={"app": len(updates.get("APP_REPOS", [])),
"mod": len(updates.get("MODULE_REPOS", []))})
return jsonify(ok=True)
except Exception as e:
return jsonify(ok=False, msg=str(e))
@app.route("/api/wizard/sync", methods=["POST"])
def api_wizard_sync():
jid, q = jobs.create("wizard:sync")
def _run(jid):
qq = jobs.get_queue(jid)
c = cfg.load()
ok_all = True
for label, repos in [("App", c.get("APP_REPOS", [])),
("Module", c.get("MODULE_REPOS", []))]:
for repo in repos:
if not repo.get("url"):
continue
jobs.log(qq, "section", f"{label}: {repo.get('name', repo['id'])}")
ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
if not ok:
ok_all = False
jobs.finish(jid, "done" if ok_all else "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/wizard/git", methods=["POST"])
def api_wizard_git():
"""Verwerk de eigen-git stap van de wizard."""
d = request.json or {}
action = d.get("action", "skip")
if action == "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
slug = url.rstrip("/").split("/")[-1].removesuffix(".git") or "eigen-git"
repo = {"id": slug, "url": url, "branch": "main"}
if d.get("token"):
repo["token"] = d["token"].strip()
if d.get("subdir"):
repo["subdir"] = d["subdir"].strip()
c = cfg.load()
repos = c.get("APP_REPOS", [])
if not any(r.get("url") == url for r in repos):
repos.append(repo)
cfg.patch({"APP_REPOS": repos})
audit.log("wizard", "git_add", "ok", detail={"url": url})
return jsonify(ok=True)
if action == "install_forgejo":
su_repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == "server-up"), None)
if not su_repo:
return jsonify(ok=False, msg="server-up repo niet geconfigureerd")
cache = git.cache_dir("server-up")
subdir = su_repo.get("subdir", "apps")
src = (cache / subdir / "forgejo").resolve() if subdir else (cache / "forgejo").resolve()
if not src.is_dir():
ok_sync, _ = git.clone_or_pull(su_repo)
if not ok_sync or not src.is_dir():
return jsonify(ok=False, msg="Forgejo boilerplate niet gevonden — sync mislukt")
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
dest = (lib / "forgejo").resolve()
if dest.exists():
return jsonify(ok=False, msg="'forgejo' bestaat al in de library")
try:
lib.mkdir(parents=True, exist_ok=True)
except Exception as e:
return jsonify(ok=False, msg=str(e))
port_web = int(d.get("port_web") or 3000)
port_ssh = int(d.get("port_ssh") or 2222)
appdata_dir = d.get("appdata_dir") or d.get("data_dir") or \
cfg.load().get("DATA_DIR", "/opt/serverup/appdata")
values = {
"service_name": "forgejo",
"appdata_dir": appdata_dir,
"timezone": "Europe/Amsterdam",
"port_web": port_web,
"port_ssh": port_ssh,
}
jid, q = jobs.create("wizard:install-forgejo")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", "Forgejo installeren")
boilerplates.render_to_dir(src, dest, values)
jobs.log(qq, "ok", f"Gerenderd naar {dest}")
ok, msg = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("wizard", "install_forgejo", "ok" if ok else "error", ref="forgejo")
jobs.finish(jid, "done" if ok else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
return jsonify(ok=True)
def _wizard_app_installeren(app_dir: str, values: dict, label: str,
audit_actie: str):
"""Installeer een app uit de ingebouwde catalogus vanuit de wizard.
Dezelfde stappen als bij Forgejo: bronmap uit de git-cache halen (en zo
nodig eerst syncen), renderen naar de library en starten. Retourneert
(jsonify-antwoord, status) zodat de aanroeper alleen hoeft door te geven.
"""
su_repo = next((r for r in cfg.load().get("APP_REPOS", [])
if r["id"] == "server-up"), None)
if not su_repo:
return jsonify(ok=False, msg="server-up repo niet geconfigureerd"), 200
cache = git.cache_dir("server-up")
subdir = su_repo.get("subdir", "apps")
src = (cache / subdir / app_dir).resolve() if subdir else (cache / app_dir).resolve()
if not src.is_dir():
ok_sync, _ = git.clone_or_pull(su_repo)
if not ok_sync or not src.is_dir():
return jsonify(ok=False,
msg=f"Sjabloon '{app_dir}' niet gevonden — sync mislukt"), 200
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
dest = (lib / app_dir).resolve()
if dest.exists():
return jsonify(ok=False, msg=f"'{app_dir}' bestaat al in de library"), 200
try:
lib.mkdir(parents=True, exist_ok=True)
except Exception as e:
return jsonify(ok=False, msg=str(e)), 200
jid, q = jobs.create(f"wizard:install-{app_dir}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"{label} installeren")
boilerplates.render_to_dir(src, dest, values)
jobs.log(qq, "ok", f"Gerenderd naar {dest}")
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m),
name=app_dir)
ok = rc == 0
jobs.log(qq, "ok" if ok else "error",
f"{label} gestart" if ok else f"{label} startte niet")
audit.log("wizard", audit_actie, "ok" if ok else "error", ref=app_dir)
jobs.finish(jid, "done" if ok else "error")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid), 200
@app.route("/api/wizard/pangolin", methods=["POST"])
def api_wizard_pangolin():
"""De Pangolin-stap: koppelen aan een bestaande, of er hier een neerzetten.
Server Up kan al publieke URL's aanmaken in Pangolin (zie core/pangolin.py),
maar je moest Pangolin zelf ergens anders vandaan halen. Deze stap sluit dat
gat aan het begin, waar je erover nadenkt.
"""
d = request.json or {}
actie = d.get("action", "skip")
if actie == "settings":
url = (d.get("url") or "").strip().rstrip("/")
if not url:
return jsonify(ok=False, msg="Adres vereist")
cfg.patch({"PANGOLIN_URL": url,
"PANGOLIN_API_KEY": (d.get("api_key") or "").strip(),
"PANGOLIN_ORG_ID": (d.get("org_id") or "").strip()})
audit.log("wizard", "pangolin", "ok", ref=url)
return jsonify(ok=True)
if actie == "install_newt":
antwoord, _ = _wizard_app_installeren("newt", {
"service_name": "newt",
"timezone": cfg.tijdzone(),
"pangolin_endpoint": (d.get("endpoint") or "").strip(),
"newt_id": (d.get("newt_id") or "").strip(),
"newt_secret": (d.get("newt_secret") or "").strip(),
}, "Newt", "install_newt")
return antwoord
if actie == "install_pangolin":
basis = (d.get("base_domain") or "").strip()
if not basis:
return jsonify(ok=False, msg="Basisdomein vereist")
dashboard = (d.get("dashboard_domain") or "").strip() or f"pangolin.{basis}"
antwoord, _ = _wizard_app_installeren("pangolin", {
"service_name": "pangolin",
"timezone": cfg.tijdzone(),
"basis_domein": basis,
"dashboard_domein": dashboard,
"admin_email": (d.get("admin_email") or "").strip() or f"admin@{basis}",
"server_secret": secrets.token_hex(32),
"port_http": 80, "port_https": 443,
"port_wireguard": 51820, "port_gerbil": 21820,
}, "Pangolin", "install_pangolin")
# Meteen koppelen: de publiceerknop per stack werkt dan zonder dat je
# het nog een keer in Instellingen hoeft in te vullen.
cfg.patch({"PANGOLIN_URL": f"https://{dashboard}"})
return antwoord
return jsonify(ok=True)
@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=["POST"])
def api_wizard_reset():
c = cfg.load()
c.pop("WIZARD_DONE", None)
cfg.save(c)
return jsonify(ok=True)
# ── Jobs ──────────────────────────────────────────────────────────────────────
@app.route("/api/debug/cache")
@app.route("/api/debug/cache/<rid>")
def api_debug_cache(rid=None):
"""Debug: toon cache structuur."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
if rid is None:
# Toon alle caches
cache_root = git.CACHE
if not cache_root.exists():
return jsonify(exists=False, path=str(cache_root))
repos = []
for d in sorted(cache_root.iterdir()):
if d.is_dir():
repos.append({"id": d.name, "path": str(d),
"contents": [f.name for f in sorted(d.iterdir()) if f.is_dir() and f.name != ".git"][:20]})
return jsonify(cache_path=str(cache_root), repos=repos)
cache = git.cache_dir(rid)
if not cache.exists():
return jsonify(exists=False, path=str(cache))
dirs = []
for p in sorted(cache.rglob("*")):
if p.is_dir() and ".git" not in str(p):
rel = str(p.relative_to(cache))
has_comp = docker.has_compose(p)
files = [f.name for f in p.iterdir() if f.is_file()][:10]
dirs.append({"path": rel, "has_compose": has_comp, "files": files})
return jsonify(exists=True, path=str(cache), dirs=dirs[:100])
@app.route("/api/debug/config")
def api_debug_config():
"""Debug: toon volledige config."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
return jsonify(cfg.load())
@app.route("/api/debug/modules")
def api_debug_modules():
"""Debug: probeer alle modules uit repo te laden en toon resultaat."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
from core.modules import _load_detail
results = []
c = cfg.load()
for repo in c.get("MODULE_REPOS", []):
cache = git.cache_dir(repo["id"])
subdir = repo.get("subdir", "")
base = cache / subdir if subdir else cache
if not base.exists():
results.append({"repo": repo["id"], "error": f"pad bestaat niet: {base}"})
continue
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith((".", "_")):
continue
has_json = (d / "module.json").exists()
has_init = (d / "__init__.py").exists()
init_preview = ""
if has_init:
try:
init_preview = (d / "__init__.py").read_text()[:300]
except Exception:
pass
cls, error = _load_detail(d.name, d) if has_init else (None, "geen __init__.py")
results.append({
"dir": d.name,
"path": str(d),
"has_module_json": has_json,
"has_init": has_init,
"init_preview": init_preview,
"load_ok": cls is not None,
"load_error": error,
"class_name": cls.__name__ if cls else None,
})
return jsonify(results=results)
@app.route("/api/debug/startup")
def api_debug_startup():
"""Debug: toon startup module loading log."""
if not os.environ.get("SU_DEBUG"):
return jsonify(ok=False, msg="niet beschikbaar"), 404
return jsonify(log=STARTUP_LOG, loaded=[m.ID for m in MODULES])
@app.route("/api/paths/check")
def api_path_check():
"""Kan Server Up op dit pad schrijven, en komt dat op de host terecht?
Zonder deze controle schrijft de app naar een pad binnen de container dat
niet gemount is: het lijkt te lukken, maar op de host staat er niets en je
stacks vinden hun data niet meer.
"""
uitslag = paden.bereikbaar(request.args.get("path") or "")
basis = (request.args.get("base") or "").strip()
if basis:
uitslag["afgeleid"] = cfg.afgeleide_paden(basis)
if not uitslag.get("ok"):
uitslag["msg"] = _padfout_tekst(uitslag)
# `uitslag` bevat zelf al `ok`; die betekent hier "dit pad is bruikbaar".
return jsonify(**uitslag)
@app.route("/api/paths/move", methods=["POST"])
def api_paths_move():
"""Verplaats de mappen van Server Up, met de stacks erbij.
De volgorde is de hele truc: eerst kopiëren en controleren, dan de
verwijzingen in de stacks omschrijven, dan de instellingen bijwerken, dan de
stacks weer starten — en pas als dat allemaal gelukt is, het oude weggooien.
"""
d = request.json or {}
huidig = cfg.load()
doelen = {}
for sleutel in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
nieuw = (d.get(sleutel) or "").strip().rstrip("/")
if nieuw and nieuw != (huidig.get(sleutel) or "").rstrip("/"):
doelen[sleutel] = nieuw
if not doelen:
return jsonify(ok=False, msg="Geen enkele map is gewijzigd.")
# Vóór er iets gebeurt: is elk doel bruikbaar, en past het?
for sleutel, nieuw in doelen.items():
controle = paden.bereikbaar(nieuw)
if not controle.get("ok"):
return jsonify(**{**controle, "msg": _padfout_tekst(controle)}), 400
bron = Path(huidig[sleutel])
if bron.is_dir():
past, waarom = paden.past_het(bron, Path(nieuw))
if not past:
return jsonify(ok=False, msg=waarom), 400
jid, q = jobs.create("paths:move")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
library = Path(huidig["LIBRARY_DIR"])
gestart = []
opgeruimd = False
try:
jobs.log(qq, "section", "Mappen verplaatsen")
# 1. Alles stoppen: een container die tijdens het kopiëren
# doorschrijft levert een halve kopie op.
for st in paden.stacks_metadata(library):
if any(c.get("running") for c in docker.compose_ps(st["pad"], name=st["naam"])):
gestart.append(st["naam"])
jobs.log(qq, "dim", f"Stoppen: {st['naam']}")
docker.compose_down(st["pad"], name=st["naam"])
# 2. Kopiëren, met voortgang over het geheel.
totaal_bytes = sum(paden.meet(Path(huidig[k]))[1] for k in doelen)
al_gedaan = [0]
for sleutel, nieuw in doelen.items():
bron = Path(huidig[sleutel])
if not bron.is_dir():
jobs.log(qq, "warn", f"{bron} bestaat niet — overgeslagen")
continue
basis = al_gedaan[0]
def voortgang(gedaan, _tot, label, _b=basis):
jobs.voortgang(jid, _b + gedaan, totaal_bytes, label)
paden.verhuis(bron, Path(nieuw), log_fn=lf,
voortgang_fn=voortgang, verwijder_bron=False)
al_gedaan[0] += paden.meet(bron)[1]
# 3. De stacks laten weten waar hun data nu staat.
nieuwe_library = Path(doelen.get("LIBRARY_DIR", huidig["LIBRARY_DIR"]))
vervangingen = {huidig[k]: v for k, v in doelen.items()}
n = paden.schrijf_paden_om(nieuwe_library, vervangingen, log_fn=lf)
jobs.log(qq, "ok", f"{n} bestand(en) met oude paden bijgewerkt")
# 4. Pas nu de instellingen.
cfg.patch(doelen)
jobs.log(qq, "ok", "Instellingen bijgewerkt")
# 5. Terugzetten wat draaide, op de nieuwe plek.
for naam in gestart:
st = nieuwe_library / naam
if docker.has_compose(st):
jobs.log(qq, "dim", f"Starten: {naam}")
docker.compose_up(st, log_fn=lf, name=naam)
# 6. Het oude verdwijnt als laatste — pas als de rest werkt.
for sleutel in doelen:
oud = Path(huidig[sleutel])
if oud.is_dir():
shutil.rmtree(oud, ignore_errors=True)
opgeruimd = True
jobs.log(qq, "ok", f"Oude map opgeruimd: {oud}")
audit.log("settings", "paths_move", "ok",
ref=", ".join(doelen.values()), ip=ip)
jobs.log(qq, "ok", "Verplaatsen voltooid")
jobs.finish(jid, "done")
except Exception as e:
jobs.log(qq, "error", str(e))
# Alleen geruststellen als het ook waar is: struikelt hij ná het
# opruimen, dan staat het oude er juist niet meer.
jobs.log(qq, "warn",
"De nieuwe mappen staan klaar, maar het oude is al "
"opgeruimd — controleer de instellingen."
if opgeruimd else
"De oude mappen staan er nog; er is niets weggegooid.")
audit.log("settings", "paths_move", "error", detail=str(e)[:200])
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
def _padfout_tekst(controle: dict) -> str:
"""Een melding die zegt wat je moet dóén, niet alleen dat het misging."""
reden = controle.get("reden")
if reden == "geen_absoluut_pad":
return "Geef een volledig pad op, beginnend met /."
if reden == "buiten_mount":
voorstel = controle.get("voorstel_base") or controle.get("pad")
return (f"Server Up kan niet bij {controle.get('pad')}: die map is niet "
f"in de container gekoppeld. Zet BASE_DIR={voorstel} in je .env "
f"naast docker-compose.yml en draai `docker compose up -d`; "
f"daarna kun je hier verder.")
if reden == "niet_schrijfbaar":
return (f"Kan niet schrijven in {controle.get('pad')}: "
f"{controle.get('detail', 'onbekende fout')}")
return "Dit pad is niet bruikbaar."
@app.route("/api/jobs/<jid>")
def api_job(jid):
off = int(request.args.get("offset", 0))
return jsonify(jobs.stream(jid, off))
# ── Boot helpers ─────────────────────────────────────────────────────────────
def _apply_boot_repos():
"""Sla SU_BOOT_REPOS repos op in config.json als ze er nog niet in staan.
Zorgt dat repos uit de env-var ook na een herstart beschikbaar blijven,
ook als SU_BOOT_REPOS later verwijderd wordt.
"""
raw = os.environ.get("SU_BOOT_REPOS", "").strip()
if not raw:
return
try:
extra = json.loads(raw)
except Exception as e:
print(f" [boot] SU_BOOT_REPOS parse fout: {e}")
return
c = cfg.load()
existing_ids = {r.get("id") for r in c.get("APP_REPOS", [])}
new_repos = [r for r in extra
if isinstance(r, dict) and r.get("id") and r["id"] not in existing_ids]
if new_repos:
cfg.patch({"APP_REPOS": list(c.get("APP_REPOS", [])) + new_repos})
for r in new_repos:
print(f" [boot] repo opgeslagen: {r.get('id')} ({r.get('url', '')})")
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
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 False
if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)):
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 False
mislukt = []
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
try:
meta = backups.create(d.name, reden=f"gepland-{plan}")
print(f" [backup] {meta['file']}")
audit.log("backups", "scheduled", "ok", ref=d.name)
except Exception as e:
print(f" [backup] {d.name} mislukt: {e}")
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
mislukt.append(f"{d.name}: {e}")
backups.prune()
# Eén melding voor de hele ronde, niet één per stack: bij een volle schijf
# faalt alles tegelijk en wil je geen twintig berichten.
if mislukt:
notify.send("backup_failed",
f"{len(mislukt)} backup(s) mislukt",
"De geplande backup is niet gelukt voor:\n\n"
+ "\n".join(f"{r}" for r in mislukt))
_meld_weinig_ruimte()
return True
def _check_gestopte_stacks() -> bool:
"""Meld containers die uit staan terwijl ze zouden moeten draaien.
"Zou moeten draaien" leiden we af uit het herstartbeleid: een container met
`restart: always`/`unless-stopped` die toch niet draait, is gecrasht of
heeft niet meer kunnen starten. Containers die je zelf hebt gestopt hebben
dat beleid niet, of staan op 'no' — die horen hier niet in.
Alleen de overgang wordt gemeld: zonder die administratie krijg je elk uur
hetzelfde bericht totdat je het oplost.
"""
if not cfg.load().get("STACK_DOWN_CHECK", True):
return False
try:
containers = docker.list_containers(alle=True)
except Exception:
return False
lib = str(Path(cfg.load()["LIBRARY_DIR"]).resolve())
gestopt = {}
for c in containers:
if c.get("running") or not c.get("working_dir", "").startswith(lib):
continue
# Handmatig gestopt of netjes afgesloten met exit 0: geen storing.
status = (c.get("status") or "").lower()
if "exited (0)" in status:
continue
gestopt[c["name"]] = f"{c['name']} ({c.get('project') or '?'}): {c.get('status') or c.get('state')}"
eerder = cfg.load().get("STACK_DOWN_LAST") or {}
nieuw = {n: t for n, t in gestopt.items() if n not in eerder}
if nieuw:
notify.send("stack_down", f"{len(nieuw)} container(s) gestopt",
"Deze containers draaien niet meer:\n\n"
+ "\n".join(f"{t}" for t in nieuw.values()))
audit.log("stacks", "down", "error", ref=",".join(nieuw)[:200])
if set(gestopt) != set(eerder):
cfg.patch({"STACK_DOWN_LAST": gestopt})
return True
def _geplande_schijfcontrole() -> bool:
"""Losse controle, zodat je ook zonder geplande backups gewaarschuwd wordt."""
_meld_weinig_ruimte()
return True
def _meld_weinig_ruimte():
"""Waarschuw als een van de schijven onder de drempel zakt.
Alleen bij de overgang van 'genoeg' naar 'krap': anders komt er elke zes uur
hetzelfde bericht zolang de schijf vol blijft.
"""
try:
st = diskspace.status()
except Exception:
return
krap = [v for v in st.get("volumes", []) if v.get("low")]
namen = sorted(v["path"] for v in krap)
eerder = cfg.load().get("DISK_LOW_LAST") or []
if namen != list(eerder):
cfg.patch({"DISK_LOW_LAST": namen})
if not krap or namen == list(eerder):
return
regels = [f"{v['label']} ({v['path']}): {v['free_human']} vrij "
f"van {v['total_human']} ({v['pct_free']}%)" for v in krap]
notify.send("disk_low", "Weinig schijfruimte",
"Server Up heeft weinig ruimte over:\n\n" + "\n".join(regels))
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.
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")
scheduler.register("stacks_down", _check_gestopte_stacks,
interval_uren=0.25, enabled_key="STACK_DOWN_CHECK")
scheduler.register("schijfruimte", _geplande_schijfcontrole,
interval_uren=6.0)
def _boot_sync():
"""Sync alle geconfigureerde repos bij elke start (achtergrond-thread).
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:
if not repo.get("url"):
continue
rid = repo.get("id", "?")
try:
ok, msg = git.clone_or_pull(repo, log_fn=None)
status = "ok" if ok else "error"
print(f" [boot] sync {label} {rid}: {status}"
+ (f"{msg[:80]}" if not ok else ""))
audit.log("core", "boot_sync", status, ref=rid,
detail=msg[:100] if not ok else "")
except Exception as e:
print(f" [boot] sync {label} {rid}: fout — {e}")
audit.log("core", "boot_sync", "error", ref=rid, detail=str(e))
# ══════════════════════════════════════════════════════════════════════════════
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:")
_apply_boot_repos()
print("Modules:")
load_modules()
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("Geplande taken:")
_registreer_taken()
scheduler.start()
print(" backups + updatecheck geregistreerd")
print("Boot sync (achtergrond):")
jobs.run(_boot_sync)
print(f"\n→ http://localhost:{port}\n")
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")