All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 13m41s
- Nieuwe metadata-sleutel depends_on: een sjabloon kan zeggen welke andere app het nodig heeft en waarom. Het installatiescherm toont dat voor het invullen, met een knop naar dat sjabloon. Advies, geen blokkade -- de broker of Home Assistant kan net zo goed op een andere machine staan - De reden is verplicht; een waarschuwing zonder uitleg klik je weg. De korte vorm ["mosquitto"] werkt ook, voor sjablonen uit externe repo's - Toegepast op acht sjablonen waar het gemis onzichtbaar was: zigbee2mqtt, alloy, alertmanager, node-exporter, cadvisor, collabora, double-take, soularr. Niet op element, open-webui, exportarr, unpackerr en onlyoffice: die werken met meerdere alternatieven, en depends_on kent geen "een van deze" - Vijftien domotica-apps, van 12 naar 27 in die categorie: Piper, Whisper en openWakeWord (spraakbediening van Home Assistant, tot nu toe volledig afwezig), Matter Server, Matter Hub, AppDaemon, go2rtc, Double Take, Domoticz, openHAB, DSMR-reader (P1-poort slimme meter), rtl_433 (433 MHz sensoren naar MQTT), Traccar, Snapcast en EMQX - Catalogus van 241 naar 256 apps; alle 285 images gecontroleerd Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
3573 lines
145 KiB
Python
3573 lines
145 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, 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",
|
|
)
|
|
|
|
# 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"):
|
|
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>/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
|
|
|
|
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
|
|
if not docker.has_compose(d):
|
|
continue
|
|
ct = docker.compose_ps(d, name=d.name)
|
|
logo_url, icon = _stack_logo(d)
|
|
meta = _stack_meta(d)
|
|
out.append({
|
|
"name": d.name, "path": str(d), "has_compose": True,
|
|
"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")),
|
|
"categories": categories.normalize(
|
|
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
|
|
})
|
|
return jsonify(out)
|
|
|
|
|
|
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():
|
|
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", [])
|
|
result = [{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos]
|
|
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):
|
|
repos = [r for r in cfg.load().get("APP_REPOS", []) if r["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()
|
|
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
|
|
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
|
|
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)
|
|
|
|
|
|
@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/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")
|