commit 5580d5ec69b841098ddd2e2384b88282081f81d7 Author: bes-r Date: Sat May 9 13:55:29 2026 +0000 Initial commit: Server Up v0.3.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9460c99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bbdc44b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# v0.3.0 β€” UI rebuild + Boilerplates support + +## Hoogtepunten + +### 🎨 Nieuwe UI (Tailwind + Alpine + HTMX) +- `templates/index.html` is volledig herschreven. De handgeschreven CSS + (`--bg/--s1/...` variabelen, ad-hoc grid-classes) is vervangen door + Tailwind utility-classes met een gematchte donker/licht-palette. +- Statebeheer via Alpine.js: één `app()` component bovenop het hele document, + geen `$=document.getElementById`-spaghetti meer. +- HTMX is geladen voor toekomstige server-rendered partials. Het bestaande + fetch-RPC patroon blijft werken; HTMX kan progressief worden ingezet. +- Modals, toasts, terminal-overlay en first-run wizard zitten allemaal in + één Alpine-state β€” geen losse globale variabelen meer. +- Mobiele sidebar gedraagt zich nu correct (slide-in i.p.v. layout-flip). + +### 🧩 ChristianLempa Boilerplates ondersteund +Server Up herkent nu twee stack-formaten naast elkaar: + +| Formaat | Detectie | Bron | +|---|---|---| +| **Compose** (origineel) | `compose.yml` / `docker-compose.yml` (+ optioneel `stack.json`) | bes-r/server-up | +| **Boilerplate** (nieuw) | `template.json` + `files/` | ChristianLempa/boilerplates-library | + +Nieuw bestand `app/core/boilerplates.py`: +- `is_boilerplate(d)` β€” detectie +- `metadata(d)` β€” converteert `template.json["metadata"]` (incl. selfhst-icons) naar Server-Up formaat +- `fields(d)` β€” flattened variable-schema voor de install-modal +- `render_to_dir(src, dest, values)` β€” rendert `files/*.yaml` met de Jinja-achtige + `<< var >>` + `<%- if expr %>` syntax die de boilerplates gebruiken (Jinja2 met + custom delimiters). Niet-tekst bestanden worden verbatim gekopieerd. + +`app/core/git.py` β†’ `_scan_compose_dirs` herkent beide formaten en zet +`format: "boilerplate"` in de stack-entry zodat de UI er een badge bij kan tonen. + +`app/app.py`: +- `_find_stack_src` accepteert ook boilerplate-mappen. +- `POST /api/store/preview` retourneert voor boilerplates het variabelen-schema + (`fields`) plus een gerenderde preview met defaults. +- `POST /api/store/install` met body `{values: {...}}` rendert de templates + voor je voordat de stack gestart wordt. + +### βš™οΈ Default-repos +Een nieuwe Server Up komt nu uit de doos met twee app-repositories: +1. `bes-r/server-up` β€” eigen stacks, submap `apps` +2. `ChristianLempa/boilerplates-library` β€” community templates, submap `compose` + +### πŸ“¦ Versie / Dockerfile +- `SU_VERSION` = `0.3.0` in `Dockerfile` en `docker-compose.yml` +- `requirements.txt`: Jinja2 expliciet toegevoegd (was al een Flask-dep) + +## Migratie vanaf v0.2.29 +- Build opnieuw: `docker compose up -d --build --no-cache` +- Bestaande stacks blijven werken (legacy formaat is intact). +- De Boilerplates-repo wordt automatisch toegevoegd voor verse installs. + Bestaande gebruikers kunnen de repo handmatig toevoegen via + Instellingen β†’ Git Repositories met submap `compose`. + +## Bekende beperkingen +- Boilerplate-templates met onbekende custom-filters of complexe Ansible-style + conditionals kunnen falen β€” de fout verschijnt in het terminal-paneel. +- `volumes:` blokken die conditioneel zijn (`<%- if volume_mode == 'local' %>`) + werken; complexere render-logica (loops over services) is nog niet getest. +- HTMX is geladen maar de meeste interacties draaien nog op fetch-RPC. Verdere + migratie naar server-rendered partials kan stapsgewijs in volgende releases. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b270d9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM python:3.12-slim AS build +WORKDIR /app +COPY app/requirements.txt . +RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt + +FROM python:3.12-slim +LABEL org.opencontainers.image.title="Server Up" org.opencontainers.image.version="0.3.0" +RUN apt-get update && apt-get install -y --no-install-recommends \ + git openssh-client curl tar gzip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Docker CLI + docker-compose inside container +RUN DPKG_ARCH=$(dpkg --print-architecture) \ + && case "$DPKG_ARCH" in \ + amd64) DOCKER_ARCH=x86_64; COMPOSE_ARCH=x86_64 ;; \ + arm64) DOCKER_ARCH=aarch64; COMPOSE_ARCH=aarch64 ;; \ + armhf) DOCKER_ARCH=armhf; COMPOSE_ARCH=armv7 ;; \ + *) DOCKER_ARCH=$DPKG_ARCH; COMPOSE_ARCH=$DPKG_ARCH ;; \ + esac \ + && curl -fsSL "https://download.docker.com/linux/static/stable/${DOCKER_ARCH}/docker-27.5.1.tgz" \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker \ + && chmod +x /usr/local/bin/docker \ + && curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-${COMPOSE_ARCH}" \ + -o /usr/local/bin/docker-compose \ + && chmod +x /usr/local/bin/docker-compose \ + && mkdir -p /usr/local/lib/docker/cli-plugins /usr/libexec/docker/cli-plugins \ + && ln -sf /usr/local/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \ + && ln -sf /usr/local/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose + +COPY --from=build /inst /usr/local +WORKDIR /app +COPY app/ ./ +COPY modules/ ./modules-bundled/ +RUN mkdir -p static/fonts \ + && curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \ + && curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \ + && sed -i "s|https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/||g" static/fonts/mdi.min.css + +# Directories + git safe.directory (fix dubious ownership) +RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \ + && printf '[safe]\n\tdirectory = *\n' > /root/.gitconfig + +ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \ + SU_VERSION=0.3.0 \ + SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \ + GIT_SSH_COMMAND="ssh -F /dev/null -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +EXPOSE 5000 +HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/api/docker/info || exit 1 +CMD ["python","app.py"] diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..e2a1f2e --- /dev/null +++ b/app/app.py @@ -0,0 +1,1343 @@ +"""Server Up β€” Docker Manager.""" +from __future__ import annotations +import inspect, json, os, re, shutil, subprocess, sys, time +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 + +import core as cfg +from core import audit, jobs, i18n +from core import docker, git, boilerplates +from core.modules import Module, CORE, discover + +app = Flask(__name__, static_folder="static", template_folder="templates") + +VERSION = os.environ.get("SU_VERSION", "0.3.0") + +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 +# ══════════════════════════════════════════════════════════════════════════════ + +@app.route("/") +def index(): + resp = render_template("index.html") + return resp + + +@app.after_request +def no_cache(response): + if request.path == "/" or request.path.endswith(".html"): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + 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/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=["GET", "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([]) + 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) + out.append({ + "name": d.name, "path": str(d), "has_compose": True, + "running": any(c["running"] for c in ct), + "containers": ct, + }) + return jsonify(out) + + +@app.route("/api/stacks//", methods=["POST"]) +def api_stack_action(name, action): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + if not d.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": + 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 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": + bd = Path(cfg.load()["BACKUP_DIR"]).resolve() + bd.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) + subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"), + "-C", str(pdir.parent), name], + capture_output=True, timeout=300) + jobs.log(qq, "ok", f"Backup β†’ {bd / f'{safe_name}_{ts}.tar.gz'}") + 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//env") +def api_stack_env(name): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + return jsonify(content=docker.read_env(d)) + + +@app.route("/api/stacks//env", methods=["PUT"]) +def api_stack_env_put(name): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + docker.write_env(d, (request.json or {}).get("content", "")) + return jsonify(ok=True) + + +@app.route("/api/stacks//compose") +def api_stack_compose(name): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + return jsonify(content=docker.read_compose(d)) + + +@app.route("/api/stacks//compose", methods=["PUT"]) +def api_stack_compose_put(name): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + docker.write_compose(d, (request.json or {}).get("content", "")) + return jsonify(ok=True) + + +@app.route("/api/stacks//logs") +def api_stack_logs(name): + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / name).resolve() + if not d.is_relative_to(lib): + return jsonify(ok=False, msg="ongeldig pad"), 400 + return jsonify(logs=docker.compose_logs(d, name=name)) + + +# ── App Store ───────────────────────────────────────────────────────────────── + +@app.route("/api/repos") +def api_repos(): + repos = 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") + repos = list(cfg.load().get("APP_REPOS", [])) + rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time())) + 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/", 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//sync", methods=["GET", "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//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.""" + 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 + + +@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 = boilerplates.fields(src) + # Render een preview met defaults, zodat de gebruiker ziet wat er komt + try: + ctx = boilerplates.build_context(src, {}) + 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)[:4000] + except Exception as e: + preview_files = {"_error": f"render: {e}"} + return jsonify( + ok=True, + stack=stack, + repo_id=rid, + format="boilerplate", + metadata=meta, + fields=flds, + 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="", + ) + + # ── 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") + result["env_vars"].append({ + "key": k, "value": v, + "is_port": is_port, "is_tag": is_tag, + "desc": "Poort" if is_port else "Image tag" if is_tag else "", + }) + if is_port: + try: + result["ports"].append({"key": k, "value": int(v)}) + except ValueError: + result["ports"].append({"key": k, "value": v}) + + # 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 + if not stack: + return jsonify(ok=False, msg="stack vereist") + + 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") + + 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 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) + 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) + jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)") + for k, v in sorted(values.items()): + jobs.log(qq, "dim", f" {k}={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 + 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") + + # Data dir + try: + Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True) + 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) + + +# ── 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//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//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.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") + repos = list(cfg.load().get("MODULE_REPOS", [])) + repos.append({"id": d.get("id", url.split("/")[-1].replace(".git", ""))[:30], + "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/", 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//sync", methods=["GET", "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 ────────────────────────────────────────────────────────────────── + +@app.route("/api/settings") +def api_settings(): + c = cfg.load() + hide = {"WIZARD_DONE", "ACTIVE_MODULES"} + return jsonify({k: v for k, v in c.items() if k not in hide}) + + +@app.route("/api/settings", methods=["PUT"]) +def api_settings_put(): + d = request.json or {} + hide = {"WIZARD_DONE", "ACTIVE_MODULES"} + updates = {k: v for k, v in d.items() if k not in hide} + 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//settings") +def api_mod_settings_get(mid): + mc = cfg.load().get("MODULE_SETTINGS", {}).get(mid, {}) + return jsonify(config=mc) + + +@app.route("/api/modules//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(): + return jsonify(entries=audit.query(200), total=audit.count()) + + +@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/") +def api_i18n_lang(lang): + return jsonify(strings=i18n.strings(lang)) + + +# ── 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=["GET", "POST"]) +def api_wizard_paths(): + if request.method == "GET": + return jsonify(ok=True, msg="use POST") + 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=["GET", "POST"]) +def api_wizard_repos(): + """Sla repos op vanuit de wizard.""" + if request.method == "GET": + return jsonify(ok=True, msg="use POST") + try: + d = request.json or {} + updates = {} + app_repos = d.get("APP_REPOS") + if app_repos is not None: + updates["APP_REPOS"] = [r for r in app_repos if r.get("url", "").strip()] + mod_repos = d.get("MODULE_REPOS") + if mod_repos is not None: + updates["MODULE_REPOS"] = [r for r in mod_repos if r.get("url", "").strip()] + 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=["GET", "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") + 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) + data_dir = d.get("data_dir", cfg.load().get("DATA_DIR", "/opt/serverup/appdata")) + values = { + "service_name": "forgejo", + "data_dir": data_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=["GET", "POST"]) +def api_wizard_done(): + cfg.patch({"WIZARD_DONE": True}) + audit.log("wizard", "complete", "ok") + return jsonify(ok=True) + + +@app.route("/api/wizard/reset", methods=["GET", "POST"]) +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/") +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/") +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 _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. + """ + import time + time.sleep(2) + c = cfg.load() + 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)) + + +# ══════════════════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + print(f"\nπŸš€ Server Up Β· http://0.0.0.0:{port}\n") + audit.init() + i18n.load() + print("Boot repos:") + _apply_boot_repos() + print("Modules:") + load_modules() + if not MODULES: + print(" (geen)") + audit.log("core", "startup", "ok") + print("Boot sync (achtergrond):") + jobs.run(_boot_sync) + print(f"\nβ†’ http://localhost:{port}\n") + app.run(host="0.0.0.0", port=port, debug=False, threaded=True) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..b1b54dd --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,134 @@ +"""Configuration β€” JSON backed, atomic writes.""" +import json, os, re, tempfile, threading +from pathlib import Path + +DEFAULTS = { + "LIBRARY_DIR": "/opt/serverup/stacks", + "DATA_DIR": "/opt/serverup/appdata", + "BACKUP_DIR": "/opt/serverup/backups", + "APP_REPOS": [ + {"id":"server-up","name":"server-up", + "url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"apps"}, + # ChristianLempa Boilerplates β€” automatisch herkend via template.json + {"id":"boilerplates","name":"Boilerplates (ChristianLempa)", + "url":"https://github.com/ChristianLempa/boilerplates-library.git", + "branch":"main","subdir":"compose"}, + ], + "MODULE_REPOS": [ + {"id":"server-up","name":"server-up", + "url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"modules"}, + ], + "ACTIVE_MODULES": None, + "MODULE_SETTINGS": {}, + "LANGUAGE": "nl", + "THEME": "dark", + "WIZARD_DONE": False, +} + +_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json")) +_lock = threading.RLock() + + +def _ensure(): + try: + _path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + +def _fix_repo_url(url: str) -> str: + """Fix GitHub URLs met pad erin: https://github.com/user/repo/modules β†’ https://github.com/user/repo.git""" + if not url: + return url + m = re.match(r'^(https?://[^/]+/[^/]+/[^/]+?)(?:/.*)?$', url.rstrip('/')) + if m: + base = m.group(1) + if not base.endswith('.git'): + base += '.git' + return base + return url + + +def _sanitize_repos(repos: list) -> list: + """Fix repo URLs in a repo list.""" + for r in repos: + if isinstance(r, dict) and r.get("url"): + r["url"] = _fix_repo_url(r["url"]) + return repos + + +def load() -> dict: + _ensure() + cfg = dict(DEFAULTS) + if _path.exists(): + try: + saved = json.loads(_path.read_text()) + cfg.update(saved) + except Exception: + pass + # Herstel standaard repos als opgeslagen waarde leeg is + for key in ("APP_REPOS", "MODULE_REPOS"): + if not cfg.get(key) and DEFAULTS.get(key): + cfg[key] = list(DEFAULTS[key]) + # Fix foute URLs + for key in ("APP_REPOS", "MODULE_REPOS"): + if cfg.get(key): + _sanitize_repos(cfg[key]) + # Bescherm tegen onveilige paden + _unsafe = {"", "/", "/app", "/app/", "/etc", "/bin", "/usr", "/var", "/tmp", "/root"} + for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"): + val = cfg.get(key, "").rstrip("/") + if val in _unsafe or not val.startswith("/"): + cfg[key] = DEFAULTS[key] + # Auto-reset wizard als LIBRARY_DIR niet bestaat (verse installatie met oud volume) + if cfg.get("WIZARD_DONE") and not Path(cfg["LIBRARY_DIR"]).exists(): + cfg["WIZARD_DONE"] = False + for k in DEFAULTS: + if k in os.environ: + cfg[k] = os.environ[k] + # Herhaal veiligheidscheck na env-overrides + for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"): + val = cfg.get(key, "").rstrip("/") + if val in _unsafe or not val.startswith("/"): + cfg[key] = DEFAULTS[key] + # Injecteer SU_BOOT_REPOS in geheugen (worden niet automatisch opgeslagen) + boot_raw = os.environ.get("SU_BOOT_REPOS", "").strip() + if boot_raw: + try: + extra = json.loads(boot_raw) + existing_ids = {r.get("id") for r in cfg.get("APP_REPOS", [])} + for r in extra: + if isinstance(r, dict) and r.get("id") and r["id"] not in existing_ids: + cfg["APP_REPOS"] = list(cfg.get("APP_REPOS", [])) + [r] + existing_ids.add(r["id"]) + except Exception: + pass + return cfg + + +def save(cfg: dict): + _ensure() + # Sanitize voor opslaan + for key in ("APP_REPOS", "MODULE_REPOS"): + if cfg.get(key): + _sanitize_repos(cfg[key]) + with _lock: + fd, tmp = tempfile.mkstemp(dir=str(_path.parent), suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(cfg, f, indent=2) + Path(tmp).replace(_path) + except Exception: + try: + Path(tmp).unlink(missing_ok=True) + except Exception: + pass + raise + + +def patch(updates: dict) -> dict: + with _lock: + cfg = load() + cfg.update(updates) + save(cfg) + return cfg diff --git a/app/core/audit.py b/app/core/audit.py new file mode 100644 index 0000000..5b3e10c --- /dev/null +++ b/app/core/audit.py @@ -0,0 +1,58 @@ +"""Audit log backed by SQLite.""" +import sqlite3, time, json, os, threading +from pathlib import Path + +_db = Path(os.environ.get("SU_AUDIT", "/data/audit.db")) +_local = threading.local() + + +def init(): + _db.parent.mkdir(parents=True, exist_ok=True) + with _conn() as c: + c.execute("""CREATE TABLE IF NOT EXISTS log( + id INTEGER PRIMARY KEY, ts REAL, + src TEXT, action TEXT, status TEXT, + ref TEXT, detail TEXT, ip TEXT)""") + + +def _conn(): + if not hasattr(_local, "c") or _local.c is None: + _local.c = sqlite3.connect(str(_db), timeout=5) + _local.c.row_factory = sqlite3.Row + return _local.c + + +def log(src: str, action: str, status="ok", ref="", detail=None, ip=""): + d = json.dumps(detail) if isinstance(detail, (dict, list)) else str(detail or "") + try: + with _conn() as c: + c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) VALUES(?,?,?,?,?,?,?)", + (time.time(), src, action, status, ref, d, ip)) + except Exception: + pass + + +def query(limit=100, offset=0) -> list[dict]: + try: + with _conn() as c: + rows = c.execute("SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?", + (limit, offset)).fetchall() + return [dict(r) for r in rows] + except Exception: + return [] + + +def count() -> int: + try: + with _conn() as c: + return c.execute("SELECT count(*) FROM log").fetchone()[0] + except Exception: + return 0 + + +def clear(): + try: + with _conn() as c: + c.execute("DELETE FROM log") + except Exception: + pass diff --git a/app/core/boilerplates.py b/app/core/boilerplates.py new file mode 100644 index 0000000..2bed0e1 --- /dev/null +++ b/app/core/boilerplates.py @@ -0,0 +1,247 @@ +"""ChristianLempa Boilerplates compatibility layer. + +Detects, parses and renders templates from the boilerplates-library format: + / + template.json (metadata + variable schema) + files/ + compose.yaml (Jinja-like template with << var >> and <%- if %> syntax) + +Renders boilerplate templates into a flat directory that Server Up can manage +just like any other stack β€” i.e. a single compose file (+ optional .env). +""" +from __future__ import annotations +import json, re, shutil +from pathlib import Path +from typing import Any + +# Jinja delimiters used by the boilerplates library +# Variable expressions use << >> and statement blocks use <% %>. +_VAR_OPEN, _VAR_CLOSE = "<<", ">>" +_BLK_OPEN, _BLK_CLOSE = "<%", "%>" + + +# ── Detection ──────────────────────────────────────────────────────────────── + +def is_boilerplate(d: Path) -> bool: + """True if the directory follows the ChristianLempa boilerplate layout.""" + if not d.is_dir(): + return False + tj = d / "template.json" + fd = d / "files" + return tj.is_file() and fd.is_dir() + + +def read_template(d: Path) -> dict | None: + tj = d / "template.json" + if not tj.exists(): + return None + try: + return json.loads(tj.read_text(encoding="utf-8")) + except Exception: + return None + + +def metadata(d: Path) -> dict: + """Return Server-Up-style metadata extracted from template.json.""" + t = read_template(d) or {} + md = t.get("metadata") or {} + icon = md.get("icon") or {} + icon_url = "" + if isinstance(icon, dict): + prov = icon.get("provider") + iid = icon.get("id") + if prov == "selfhst" and iid: + icon_url = f"https://cdn.jsdelivr.net/gh/selfhst/icons/png/{iid}.png" + elif prov == "dashboard-icons" and iid: + icon_url = f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{iid}.png" + elif iid and (iid.startswith("http://") or iid.startswith("https://")): + icon_url = iid + return { + "name": md.get("name") or d.name, + "description": md.get("description", ""), + "tags": md.get("tags", []) or [], + "logo_url": icon_url, + "version": (md.get("version") or {}).get("name", ""), + "kind": t.get("kind", "compose"), + "format": "boilerplate", + "draft": bool(md.get("draft")), + } + + +# ── Variable schema β†’ Server Up form fields ────────────────────────────────── + +def fields(d: Path) -> list[dict]: + """Flatten template.json variables into a list of UI fields. + + Each field has: name, type, title, group, default, required, + options (for enum), needs (visibility deps), placeholder, description. + """ + t = read_template(d) or {} + out: list[dict] = [] + for grp in t.get("variables", []) or []: + gname = grp.get("title") or grp.get("name", "") + toggle = grp.get("toggle") + for item in grp.get("items", []) or []: + cfg = item.get("config") or {} + out.append({ + "name": item.get("name", ""), + "type": item.get("type", "str"), + "title": item.get("title") or item.get("name", ""), + "group": gname, + "group_toggle": toggle, + "default": item.get("default"), + "required": bool(item.get("required")), + "options": cfg.get("options") or [], + "needs": item.get("needs") or [], + "placeholder": cfg.get("placeholder", ""), + "description": item.get("description", ""), + }) + return out + + +# ── Templating engine ──────────────────────────────────────────────────────── + +class BoilerplateError(Exception): + pass + + +def _coerce(value: Any, typ: str): + if typ == "bool": + if isinstance(value, bool): + return value + s = str(value).strip().lower() + return s in ("1", "true", "yes", "on") + if typ == "int": + try: + return int(str(value).strip()) + except (ValueError, TypeError): + return 0 + return str(value) if value is not None else "" + + +def build_context(d: Path, values: dict[str, Any]) -> dict[str, Any]: + """Merge user-provided values with defaults declared in template.json, + coercing each value to its declared type.""" + ctx: dict[str, Any] = {} + for f in fields(d): + name = f["name"] + if name in values and values[name] not in (None, ""): + ctx[name] = _coerce(values[name], f["type"]) + elif f.get("default") is not None: + ctx[name] = _coerce(f["default"], f["type"]) + else: + # Sensible defaults so Jinja doesn't blow up on undefined vars + ctx[name] = "" if f["type"] != "bool" else False + # Allow extra values to pass through (e.g. derived service_name) + for k, v in values.items(): + if k not in ctx and v is not None: + ctx[k] = v + return ctx + + +_env = None + + +def _jinja_env(): + """Create (once) and return the Jinja2 environment for the boilerplates delimiters.""" + global _env + if _env is None: + try: + from jinja2 import Environment, ChainableUndefined + except ImportError as e: + raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e + _env = Environment( + variable_start_string=_VAR_OPEN, + variable_end_string=_VAR_CLOSE, + block_start_string=_BLK_OPEN, + block_end_string=_BLK_CLOSE, + comment_start_string="<#", + comment_end_string="#>", + trim_blocks=True, + lstrip_blocks=True, + keep_trailing_newline=True, + undefined=ChainableUndefined, + ) + return _env + + +def render_text(template: str, ctx: dict[str, Any]) -> str: + env = _jinja_env() + try: + return env.from_string(template).render(**ctx) + except Exception as e: + raise BoilerplateError(f"render failed: {e}") from e + + +def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]: + """Render every file under /files/ into /, applying the template + engine to text-like files. Binary files are copied verbatim. + + Returns the list of relative paths written. + """ + files_dir = src / "files" + if not files_dir.is_dir(): + raise BoilerplateError(f"no files/ directory in {src}") + + ctx = build_context(src, values) + dest.mkdir(parents=True, exist_ok=True) + + written: list[str] = [] + for p in sorted(files_dir.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(files_dir) + out = dest / rel + out.parent.mkdir(parents=True, exist_ok=True) + if _looks_textual(p): + try: + txt = p.read_text(encoding="utf-8") + except UnicodeDecodeError: + shutil.copy2(p, out) + written.append(str(rel)) + continue + rendered = render_text(txt, ctx) + # Drop blocks of pure whitespace left over after conditionals strip + rendered = _tidy(rendered) + out.write_text(rendered, encoding="utf-8") + else: + shutil.copy2(p, out) + written.append(str(rel)) + # Always ensure the default compose name exists; rename if needed + _normalize_compose_name(dest) + return written + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +_TEXT_EXTS = {".yml", ".yaml", ".env", ".conf", ".cfg", ".ini", ".json", + ".toml", ".sh", ".md", ".txt", ".tmpl", ".tpl", ".j2", ""} + + +def _looks_textual(p: Path) -> bool: + if p.suffix.lower() in _TEXT_EXTS: + return True + # also accept dotfiles like ".env" + if p.name.startswith(".") and not p.suffix: + return True + try: + chunk = p.read_bytes()[:512] + chunk.decode("utf-8") + return b"\x00" not in chunk + except Exception: + return False + + +def _tidy(text: str) -> str: + # Collapse 3+ blank lines into 2 β€” block conditionals leave gaps behind. + return re.sub(r"\n{3,}", "\n\n", text) + + +def _normalize_compose_name(dest: Path): + """Ensure there's a docker-compose.yml at root; rename compose.yaml if not.""" + if (dest / "docker-compose.yml").exists() or (dest / "compose.yml").exists(): + return + cy = dest / "compose.yaml" + if cy.exists(): + # Some tooling expects docker-compose.yml; symlink-style rename. + cy.rename(dest / "docker-compose.yml") diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..2f1b21e --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,2 @@ +"""Compatibility shim β€” re-export core config functions.""" +from core import load, save, patch, DEFAULTS diff --git a/app/core/docker.py b/app/core/docker.py new file mode 100644 index 0000000..bcedd48 --- /dev/null +++ b/app/core/docker.py @@ -0,0 +1,264 @@ +"""Docker CLI wrapper β€” no SDK needed.""" +import json, os, re, subprocess, shutil, threading +from pathlib import Path + +COMPOSE_NAMES = ("compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml") + + +def _bin() -> str: + for p in ("/usr/local/bin/docker", "/usr/bin/docker"): + if os.path.isfile(p): + return p + return shutil.which("docker") or "docker" + + +def _run(cmd, **kw) -> subprocess.CompletedProcess: + kw.setdefault("capture_output", True) + kw.setdefault("text", True) + kw.setdefault("timeout", 60) + try: + return subprocess.run(cmd, **kw) + except FileNotFoundError: + return subprocess.CompletedProcess(cmd, 127, "", "docker niet gevonden") + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 1, "", "timeout") + + +def _compose_base() -> list[str]: + # Probeer docker compose (plugin) + r = _run([_bin(), "compose", "version"], timeout=5) + if r.returncode == 0: + return [_bin(), "compose"] + # Probeer standalone docker-compose + dc = shutil.which("docker-compose") + if dc: + return [dc] + # Probeer compose plugin op alternatieve locaties + for plugin_dir in ("/usr/lib/docker/cli-plugins", + "/usr/libexec/docker/cli-plugins", + "/usr/local/lib/docker/cli-plugins", + os.path.expanduser("~/.docker/cli-plugins")): + compose_bin = os.path.join(plugin_dir, "docker-compose") + if os.path.isfile(compose_bin): + return [compose_bin] + # Laatste poging: docker-compose uit PATH + return [_bin(), "compose"] + + +# Cache compose base command (thread-safe) +_compose_cmd = None +_compose_lock = threading.Lock() + + +def _get_compose() -> list[str]: + global _compose_cmd + if _compose_cmd is None: + with _compose_lock: + if _compose_cmd is None: + _compose_cmd = _compose_base() + return _compose_cmd + + +def _stream(cmd, cwd=None, log_fn=None) -> int: + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=cwd) + for line in p.stdout: + s = line.rstrip() + if s and log_fn: + log_fn(s) + return p.wait() + except Exception as e: + if log_fn: + log_fn(f"Fout: {e}") + return 1 + + +# ── Info ────────────────────────────────────────────────────────────────────── + +def info() -> dict: + r = _run([_bin(), "info", "--format", "json"], timeout=10) + if r.returncode == 0: + try: + d = json.loads(r.stdout) + return {"ok": True, "version": d.get("ServerVersion", "?"), + "containers": d.get("Containers", 0), + "running": d.get("ContainersRunning", 0), + "images": d.get("Images", 0)} + except Exception: + pass + # Fallback + ver = _run([_bin(), "version", "--format", "{{.Server.Version}}"], timeout=5) + ps = _run([_bin(), "ps", "-q"], timeout=5) + imgs = _run([_bin(), "images", "-q"], timeout=5) + return { + "ok": ver.returncode == 0, + "version": ver.stdout.strip() if ver.returncode == 0 else "?", + "running": len(ps.stdout.strip().splitlines()) if ps.returncode == 0 else 0, + "containers": 0, + "images": len(imgs.stdout.strip().splitlines()) if imgs.returncode == 0 else 0, + } + + +# ── Images ──────────────────────────────────────────────────────────────────── + +def images() -> list[dict]: + fmt = "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}" + r = _run([_bin(), "images", "--format", fmt], timeout=15) + if r.returncode != 0: + return [] + out = [] + for line in r.stdout.strip().splitlines(): + p = line.split("\t") + if len(p) >= 4: + out.append({"id": p[0], "repo": p[1], "tag": p[2], + "size": p[3], "age": p[4] if len(p) > 4 else ""}) + return out + + +def rmi(image_id: str, force=False) -> tuple[bool, str]: + cmd = [_bin(), "rmi"] + if force: + cmd.append("-f") + cmd.append(image_id) + r = _run(cmd, timeout=30) + return r.returncode == 0, (r.stderr or r.stdout).strip() + + +def prune_images() -> tuple[bool, str]: + r = _run([_bin(), "image", "prune", "-af"], timeout=120) + return r.returncode == 0, (r.stdout or r.stderr).strip() + + +# ── Container management ───────────────────────────────────────────────────── + +def restart_container(name: str) -> tuple[bool, str]: + r = _run([_bin(), "restart", name], timeout=30) + return r.returncode == 0, (r.stderr or r.stdout).strip() + + +# ── Compose helpers ─────────────────────────────────────────────────────────── + +def find_compose(d: Path) -> Path | None: + for n in COMPOSE_NAMES: + f = d / n + if f.exists(): + return f + return None + + +def has_compose(d: Path) -> bool: + return find_compose(d) is not None + + +def compose_ps(d: Path, name=None) -> list[dict]: + f = find_compose(d) + if not f: + return [] + cmd = _get_compose() + ["-f", str(f)] + if name: + cmd += ["-p", name] + cmd += ["ps", "--format", "json"] + r = _run(cmd, cwd=str(d), timeout=10) + if r.returncode != 0: + return [] + out = [] + for line in r.stdout.strip().splitlines(): + try: + c = json.loads(line) + out.append({ + "name": c.get("Name", ""), + "service": c.get("Service", ""), + "state": c.get("State", ""), + "status": c.get("Status", ""), + "ports": c.get("Ports", ""), + "running": c.get("State", "").lower() in ("running", "up"), + }) + except Exception: + pass + return out + + +def compose_up(d: Path, log_fn=None, name=None) -> int: + f = find_compose(d) + if not f: + return 1 + cmd = _get_compose() + ["-f", str(f)] + if name: + cmd += ["-p", name] + cmd += ["up", "-d", "--remove-orphans"] + return _stream(cmd, cwd=str(d), log_fn=log_fn) + + +def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int: + f = find_compose(d) + if not f: + return 1 + cmd = _get_compose() + ["-f", str(f)] + if name: + cmd += ["-p", name] + cmd += ["down"] + if volumes: + cmd.append("-v") + return _stream(cmd, cwd=str(d), log_fn=log_fn) + + +def compose_pull(d: Path, log_fn=None) -> int: + f = find_compose(d) + if not f: + return 1 + cmd = _get_compose() + ["-f", str(f), "pull"] + return _stream(cmd, cwd=str(d), log_fn=log_fn) + + +def compose_logs(d: Path, tail=80, name=None) -> str: + f = find_compose(d) + if not f: + return "" + cmd = _get_compose() + ["-f", str(f)] + if name: + cmd += ["-p", name] + cmd += ["logs", "--tail", str(tail), "--no-color"] + r = _run(cmd, cwd=str(d), timeout=15) + return r.stdout if r.returncode == 0 else r.stderr + + +def read_env(d: Path) -> str: + f = d / ".env" + return f.read_text() if f.exists() else "" + + +def write_env(d: Path, content: str): + f = d / ".env" + f.write_text(content) + + +def read_compose(d: Path) -> str: + f = find_compose(d) + return f.read_text() if f else "" + + +def write_compose(d: Path, content: str): + f = find_compose(d) + if f: + f.write_text(content) + + +def used_ports() -> set[int]: + r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10) + ports = set() + if r.returncode == 0: + for m in re.findall(r"(?:0\.0\.0\.0|::):(\d+)->", r.stdout): + try: + ports.add(int(m)) + except ValueError: + pass + return ports + + +def next_free_port(start=8100) -> int: + used = used_ports() + p = start + while p in used: + p += 1 + return p diff --git a/app/core/git.py b/app/core/git.py new file mode 100644 index 0000000..3a9984b --- /dev/null +++ b/app/core/git.py @@ -0,0 +1,215 @@ +"""Git operations β€” clone, pull, scan for stacks and modules.""" +import json, os, subprocess, shutil +from pathlib import Path +from core.docker import COMPOSE_NAMES +from core import boilerplates as bp + +CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git")) + +# Fix git "dubious ownership" β€” container draait als root, cache kan door andere uid zijn +_git_safe_set = False + + +def _ensure_git_safe(): + global _git_safe_set + if not _git_safe_set: + os.environ["GIT_CONFIG_COUNT"] = "1" + os.environ["GIT_CONFIG_KEY_0"] = "safe.directory" + os.environ["GIT_CONFIG_VALUE_0"] = "*" + _git_safe_set = True + + +# Run immediately at import +_ensure_git_safe() + + +def cache_dir(repo_id: str) -> Path: + return CACHE / repo_id + + +def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]: + _ensure_git_safe() + url = repo.get("url", "").strip() + if not url: + return False, "Geen URL" + branch = repo.get("branch", "main") + token = repo.get("token", "").strip() + rid = repo.get("id", "repo") + dest = cache_dir(rid) + + clone_url = url + if token and url.startswith("https://"): + host = url.split("://", 1)[1] + clone_url = f"https://x-access-token:{token}@{host}" + + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + + try: + if dest.exists() and (dest / ".git").exists(): + if log_fn: + log_fn(f"Pull {rid}…") + r = subprocess.run(["git", "pull", "--rebase", "--autostash"], + cwd=str(dest), capture_output=True, text=True, + env=env, timeout=60) + else: + if log_fn: + log_fn(f"Clone {url}…") + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + shutil.rmtree(dest) + r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1", + clone_url, str(dest)], + capture_output=True, text=True, env=env, timeout=120) + + output = (r.stdout + "\n" + r.stderr).strip() + # Verwijder token uit output voordat het gelogd of teruggegeven wordt + safe_output = output.replace(clone_url, url) if clone_url != url else output + for line in safe_output.splitlines(): + if line.strip() and log_fn: + log_fn(line) + + if r.returncode != 0: + return False, safe_output[:200] + return True, "OK" + except subprocess.TimeoutExpired: + return False, "Timeout" + except Exception as e: + return False, str(e) + + +def scan_stacks(repo_id: str, lib: Path, subdir: str = "") -> list[dict]: + base = cache_dir(repo_id) + if subdir: + base = base / subdir + if not base.exists(): + return [] + + stacks = _scan_compose_dirs(base, lib) + + # Fallback: als geen stacks gevonden en geen subdir opgegeven, + # zoek automatisch in bekende submappen + if not stacks and not subdir: + for fallback in ("apps", "stacks", "docker"): + fb = cache_dir(repo_id) / fallback + if fb.exists(): + stacks = _scan_compose_dirs(fb, lib) + if stacks: + break + + return stacks + + +def scan_dir(base: Path, lib: Path) -> list[dict]: + """Scan een lokale map voor stacks/boilerplates zonder git (voor ingebouwde apps).""" + if not base.exists(): + return [] + return _scan_compose_dirs(base, lib) + + +def _scan_compose_dirs(base: Path, lib: Path) -> list[dict]: + """Scan één directory voor compose stacks. Detecteert zowel het klassieke + Server Up formaat (compose.yml + stack.json) als het ChristianLempa + Boilerplates formaat (template.json + files/).""" + stacks = [] + for d in sorted(base.iterdir()): + if not d.is_dir() or d.name.startswith((".", "_")): + continue + + is_legacy = any((d / n).exists() for n in COMPOSE_NAMES) + is_bp = bp.is_boilerplate(d) + if not (is_legacy or is_bp): + continue + + if is_bp: + meta = bp.metadata(d) + # boilerplate met draft=true wordt overgeslagen tenzij expliciet + if meta.get("draft"): + continue + else: + meta = _meta(d) + meta.setdefault("format", "compose") + + installed = [] + if lib.exists(): + for ld in lib.iterdir(): + if ld.is_dir() and (ld.name == d.name or ld.name.startswith(d.name + "-")): + installed.append(ld.name) + entry = { + "dir": d.name, + "name": meta.pop("name", d.name), + "path": str(d), + "instances": sorted(installed), + } + entry.update(meta) + stacks.append(entry) + return stacks + + +def scan_modules(repo_id: str, subdir: str = "") -> list[dict]: + base = cache_dir(repo_id) + if subdir: + base = base / subdir + if not base.exists(): + return [] + + # Fallback: als base geen subdirectories heeft, probeer bekende submappen + if not any(d.is_dir() for d in base.iterdir()): + if not subdir: + for fb in ("modules",): + fb_path = cache_dir(repo_id) / fb + if fb_path.exists(): + base = fb_path + break + + mods = [] + for d in sorted(base.iterdir()): + if not d.is_dir() or d.name.startswith((".", "_")): + continue + # Vereist module.json + mf = d / "module.json" + if not mf.exists(): + continue + init = d / "__init__.py" + if not init.exists(): + continue + # Valideer dat __init__.py daadwerkelijk een Module subclass bevat + try: + src = init.read_text() + if "Module" not in src or "class " not in src: + continue + except Exception: + continue + meta = {} + try: + meta = json.loads(mf.read_text()) + except Exception: + pass + mid = meta.get("id", d.name) + # Skip core features + from core.modules import CORE + if mid in CORE: + continue + mods.append({ + "id": mid, + "name": meta.get("name", d.name), + "icon": meta.get("icon", "🧩"), + "desc": meta.get("description", ""), + "version": meta.get("version", "1.0.0"), + "tags": meta.get("tags", []), + "author": meta.get("author", ""), + "repo_id": repo_id, + "_src": str(d), + }) + return mods + + +def _meta(d: Path) -> dict: + for n in ("app.json", "stack.json", "meta.json"): + f = d / n + if f.exists(): + try: + return json.loads(f.read_text()) + except Exception: + pass + return {"description": "", "icon": "", "tags": [], "ports": []} diff --git a/app/core/i18n.py b/app/core/i18n.py new file mode 100644 index 0000000..e02881d --- /dev/null +++ b/app/core/i18n.py @@ -0,0 +1,39 @@ +"""Internationalisation β€” built-in NL + EN.""" +import json +from pathlib import Path + +_DIR = Path(__file__).parent.parent / "translations" +_cache: dict[str, dict] = {} +_langs: dict[str, dict] = {} + + +def load(): + _cache.clear() + _langs.clear() + if not _DIR.exists(): + return + for f in sorted(_DIR.glob("*.json")): + try: + data = json.loads(f.read_text("utf-8")) + meta = data.get("_meta", {}) + code = meta.get("code", f.stem) + _langs[code] = meta + _cache[code] = {k: v for k, v in data.items() if not k.startswith("_")} + except Exception: + pass + + +def available() -> list[dict]: + if not _langs: + load() + return [{"code": k, "name": v.get("name", k), "flag": v.get("flag", "")} + for k, v in _langs.items()] + + +def strings(lang: str) -> dict: + if not _cache: + load() + base = dict(_cache.get("en", {})) + if lang != "en" and lang in _cache: + base.update(_cache[lang]) + return base diff --git a/app/core/jobs.py b/app/core/jobs.py new file mode 100644 index 0000000..01bb870 --- /dev/null +++ b/app/core/jobs.py @@ -0,0 +1,74 @@ +"""Background job runner with log streaming.""" +import threading, time, uuid, queue + +_jobs: dict[str, dict] = {} +_queues: dict[str, queue.Queue] = {} +_stream_locks: dict[str, threading.Lock] = {} +_lock = threading.Lock() +_TTL = 3600 + + +def _cleanup(): + """Verwijder voltooide jobs ouder dan TTL uit geheugen.""" + cutoff = time.time() - _TTL + with _lock: + stale = [jid for jid, j in _jobs.items() + if j["status"] != "running" and j["ts"] < cutoff] + for jid in stale: + _jobs.pop(jid, None) + _queues.pop(jid, None) + _stream_locks.pop(jid, None) + + +def create(tag: str) -> tuple[str, queue.Queue]: + _cleanup() + jid = uuid.uuid4().hex[:8] + q = queue.Queue() + with _lock: + _jobs[jid] = {"id": jid, "tag": tag, "status": "running", + "lines": [], "ts": time.time()} + _queues[jid] = q + _stream_locks[jid] = threading.Lock() + return jid, q + + +def get_queue(jid: str) -> queue.Queue | None: + return _queues.get(jid) + + +def log(q, level: str, text: str): + if q: + q.put({"level": level, "text": text}) + + +def finish(jid: str, status="done"): + with _lock: + if jid in _jobs: + _jobs[jid]["status"] = status + + +def done(q): + if q: + q.put(None) + + +def stream(jid: str, offset=0) -> dict: + job = _jobs.get(jid) + if not job: + return {"lines": [], "status": "unknown"} + with _stream_locks.get(jid, threading.Lock()): + q = _queues.get(jid) + if q: + while True: + try: + item = q.get_nowait() + if item is None: + break + job["lines"].append(item) + except queue.Empty: + break + return {"lines": job["lines"][offset:], "status": job["status"]} + + +def run(fn, *args): + threading.Thread(target=fn, args=args, daemon=True).start() diff --git a/app/core/modules.py b/app/core/modules.py new file mode 100644 index 0000000..707a6cc --- /dev/null +++ b/app/core/modules.py @@ -0,0 +1,161 @@ +"""Module system β€” base class + discovery.""" +from __future__ import annotations +import importlib.util, inspect, json, sys +from pathlib import Path +from flask import Blueprint + +CORE = frozenset({ + # Core feature IDs + "stacks", "app_store", "docker_images", "audit", "settings", "wizard", + "language", "i18n", "projects", "system_info", "updater", "git_browser", + "store", "dashboard", "docker", "core", "modules", + # Repo module IDs that are core features (not optional) + "audit_log", "audit-log", + "system-info", "system_info", + "git-browser", "git_browser", +}) + + +class Module: + ID = "" + NAME = "" + ICON = "🧩" + DESC = "" + VER = "1.0.0" + + # Old-style compat attributes + MODULE_ID = "" + MODULE_NAME = "" + MODULE_ICON = "" + MODULE_DESC = "" + + def __init_subclass__(cls, **kw): + """Sync old MODULE_* attrs to new ID/NAME/ICON/DESC.""" + super().__init_subclass__(**kw) + # Old-style MODULE_ID β†’ new ID (old takes priority if set) + if cls.__dict__.get("MODULE_ID"): + cls.ID = cls.MODULE_ID + if cls.__dict__.get("MODULE_NAME"): + cls.NAME = cls.MODULE_NAME + if cls.__dict__.get("MODULE_ICON"): + cls.ICON = cls.MODULE_ICON + if cls.__dict__.get("MODULE_DESC"): + cls.DESC = cls.MODULE_DESC + + def blueprint(self) -> Blueprint | None: + return None + + def pages(self) -> list[dict]: + return [] + + def on_load(self, app) -> None: + pass + + def settings_html(self) -> str | None: + return None + + def get_config(self, key=None, default=None): + """Haal module-specifieke config op.""" + import core as cfg + mc = cfg.load().get("MODULE_SETTINGS", {}).get(self.ID, {}) + if key is None: + return mc + return mc.get(key, default) + + def save_config(self, updates: dict): + """Sla module-specifieke config op.""" + import core as cfg + c = cfg.load() + ms = dict(c.get("MODULE_SETTINGS", {})) + mc = dict(ms.get(self.ID, {})) + mc.update(updates) + ms[self.ID] = mc + cfg.patch({"MODULE_SETTINGS": ms}) + + def info(self) -> dict: + return {"id": self.ID, "name": self.NAME, "icon": self.ICON, + "desc": self.DESC, "version": self.VER, + "pages": self.pages(), "core": self.ID in CORE} + + +def discover(dirs: list[Path]) -> list[tuple[str, type, Path]]: + found = [] + seen = set() + for base in dirs: + if not base.exists(): + continue + for d in sorted(base.iterdir()): + if not d.is_dir() or d.name.startswith("_"): + continue + if not (d / "__init__.py").exists(): + continue + # Vereist module.json + if not (d / "module.json").exists(): + continue + mid = d.name + if mid in seen: + continue + # Skip CORE modules (dir name check) + if mid in CORE: + continue + seen.add(mid) + meta = _meta(d) + # Skip CORE modules (meta ID check) + meta_id = meta.get("id", mid) + if meta_id in CORE: + continue + if meta.get("enabled") is False: + continue + cls = _load(mid, d) + if cls: + # Final check: skip if the class ID is CORE + try: + inst = cls() + if inst.ID in CORE: + continue + except Exception: + pass + found.append((meta.get("order", 50), mid, cls, d.resolve())) + found.sort(key=lambda x: x[0]) + return [(m, c, p) for _, m, c, p in found] + + +def _meta(d: Path) -> dict: + f = d / "module.json" + if f.exists(): + try: + return json.loads(f.read_text()) + except Exception: + pass + return {} + + +def _load(mid: str, d: Path) -> type | None: + cls, _ = _load_detail(mid, d) + return cls + + +def _load_detail(mid: str, d: Path) -> tuple[type | None, str]: + """Load module class, return (cls, error_msg).""" + try: + name = f"_mod_{mid}" + init_file = d / "__init__.py" + if not init_file.exists(): + return None, f"__init__.py niet gevonden in {d}" + spec = importlib.util.spec_from_file_location( + name, str(init_file), + submodule_search_locations=[str(d)]) + if not spec or not spec.loader: + return None, f"kon spec niet laden voor {init_file}" + pkg = importlib.util.module_from_spec(spec) + sys.modules[name] = pkg + spec.loader.exec_module(pkg) + for _, obj in inspect.getmembers(pkg, inspect.isclass): + if obj is not Module and issubclass(obj, Module) and obj.__module__ == name: + return obj, "" + # Toon welke classes er WEL zijn + classes = [n for n, o in inspect.getmembers(pkg, inspect.isclass) if o.__module__ == name] + return None, f"geen Module subclass gevonden. Classes: {classes or 'geen'}" + except Exception as e: + print(f" βœ– {mid}: {e}") + return None, f"{type(e).__name__}: {e}" diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..2d026d8 --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1 @@ +# Modules package β€” bevat base.py compatibility shim diff --git a/app/modules/base.py b/app/modules/base.py new file mode 100644 index 0000000..40de64d --- /dev/null +++ b/app/modules/base.py @@ -0,0 +1,5 @@ +"""Compatibility shim β€” oude modules importeren 'from modules.base import ModuleBase'. +Dit verwijst door naar de nieuwe core.modules.Module class.""" +from core.modules import Module as ModuleBase + +__all__ = ["ModuleBase"] diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..def39ff --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +pyyaml>=6.0 +jinja2>=3.1 diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..7734e08 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,1026 @@ + + + + + +Server Up + + + + + + + + + + + + +
+ + ServerUp + +
+ + + +
+ + + +
+ + +
+
+ + +
+
+
+
+

Dashboard

+

Overzicht

+
+ +
+
+
Running
+
Stopped
+
Images
+
Stacks
+
+
+ +
+
+ + +
+
+
+

Stacks

+ +
+
+ + +
+
+ +
+
+ +

Geen stacks gevonden

+
+
+ + +
+
+
+

App Store

+
+ + +
+
+
+ +
+
+ + +
+
+
+

Docker Images

+
+ + +
+
+
+ + + + + + + + + + + +
RepoTagSizeAge
+
+
+ + +
+
+
+

Audit Log

+ + +
+
+ + + + + + + + + + + +
TijdCategorieActieStatusRef
+
+
+ + +
+
+
+

Instellingen

+ Opgeslagen +
+ +
+
+
Thema
+
+ + +
+
+
+
Taal
+ +
+
+ +
+ Paden +
+
+
+
+
+
+ +
+ Git Repositories +
+ + +
+
+ +
+ Container herstarten +
+

Herstart de Server Up container.

+ +
+
+
+ +
+
+ + +
+
+
+ Log + +
+
+ +
+
+ + +
+ +
+ + +
+ + +
+

Repository toevoegen

+
+
+
+
+
+
+
+

Tip: voor de ChristianLempa Boilerplates gebruik je submap compose.

+
+
+ + +
+
+ + +
+

+ + Stack installeren + Boilerplate +

+ +
+
+
+ + +

Wijzig voor meerdere installaties

+
+ +
+ Configuratie laden... +
+ + + + + + +
+ +
+ + +
+
+ + +
+

Verwijderen

+

Stack:

+
+ + +
+
+ +
+
+ + +
+

+ +
+ + +
+
+ + +
+

+

+    
+ +
+
+
+ + +
+
+
+
+

+

+
+

Doorloop deze korte wizard om Server Up te configureren.

+
    +
  • βœ” Beheer Docker stacks via één UI
  • +
  • βœ” Installeer apps uit je eigen repo of de Boilerplates
  • +
  • βœ” Modules toevoegen voor MQTT, Home Assistant, etc.
  • +
+
+
+
+
+
+
+
+

Server Up start met deze repos. Je kunt later meer toevoegen via Instellingen β†’ Git Repositories.

+
    +
  • bes-r/server-up β€” eigen stacks & modules (submap: apps / modules)
  • +
  • ChristianLempa/boilerplates-library β€” community templates (submap: compose)
  • +
+
+ +
+
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+

Forgejo wordt geΓ―nstalleerd en gestart. Daarna kun je er zelf repos in aanmaken voor eigen boilerplates.

+
+
+ +
+

We synchroniseren nu de repos en zijn klaar voor gebruik.

+
+
+
+ + +
+
+
+ + + + + + + diff --git a/app/translations/en.json b/app/translations/en.json new file mode 100644 index 0000000..14b21ff --- /dev/null +++ b/app/translations/en.json @@ -0,0 +1,92 @@ +{ + "_meta": {"code": "en", "name": "English", "flag": "πŸ‡¬πŸ‡§"}, + "dashboard": "Dashboard", + "stacks": "Stacks", + "app_store": "App Store", + "images": "Docker Images", + "audit": "Audit Log", + "settings": "Settings", + "modules": "Modules", + "overview": "Overview of your Docker environment", + "running": "Running", + "stopped": "Stopped", + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "update": "Update", + "remove": "Remove", + "install": "Install", + "save": "Save", + "saved": "Saved", + "cancel": "Cancel", + "close": "Close", + "search": "Search", + "loading": "Loading…", + "no_results": "No results", + "refresh": "Refresh", + "add": "Add", + "delete": "Delete", + "edit": "Edit", + "confirm": "Confirm", + "prune": "Prune", + "pull": "Pull", + "clone": "Clone", + "sync": "Sync", + "logs": "Logs", + "backup": "Backup", + "env_editor": "Edit .env", + "compose_editor": "Edit compose", + "paths": "Paths", + "library_dir": "Library directory", + "data_dir": "Data directory", + "backup_dir": "Backup directory", + "theme": "Theme", + "language": "Language", + "git": "Git", + "docker": "Docker", + "no_stacks": "No stacks found", + "no_images": "No images found", + "no_repos": "No repositories", + "add_repo": "Add repository", + "repo_name": "Name", + "repo_url": "Git URL", + "repo_branch": "Branch", + "repo_subdir": "Subdirectory", + "install_stack": "Install stack", + "instance_name": "Instance name", + "instance_hint": "Change for multiple installations", + "remove_stack": "Remove stack", + "remove_stack_only": "Stop containers + volumes", + "remove_all": "Stop + remove all files", + "prune_images": "Remove all unused images?", + "confirm_remove_image": "Remove image?", + "force_remove": "Force removal?", + "container_restart": "Restart container", + "container_restart_hint": "Restart the Server Up container", + "container_restarting": "Container is restarting…", + "wizard": "Setup Wizard", + "wizard_open": "Open wizard", + "wizard_reset": "Run again", + "wizard_welcome": "Welcome to Server Up", + "wizard_welcome_sub": "This wizard guides you through first-time setup. All steps are optional.", + "wizard_paths": "Set paths", + "wizard_paths_sub": "Where should your Docker projects be stored?", + "wizard_git": "Git repositories", + "wizard_git_sub": "Synchronise your stacks and modules.", + "wizard_done": "All done!", + "wizard_done_sub": "Setup complete. Change anything later in Settings.", + "prev": "Previous", + "next": "Next", + "finish": "Finish", + "ready": "Ready", + "busy": "Busy", + "done": "Done", + "error": "Error", + "optional_modules": "Optional modules", + "module_repos": "Module repositories", + "installed": "Installed", + "available": "Available", + "synced": "Synced", + "not_cloned": "Not cloned", + "auto_saved": "Auto-saved" +} diff --git a/app/translations/nl.json b/app/translations/nl.json new file mode 100644 index 0000000..9475992 --- /dev/null +++ b/app/translations/nl.json @@ -0,0 +1,92 @@ +{ + "_meta": {"code": "nl", "name": "Nederlands", "flag": "πŸ‡³πŸ‡±"}, + "dashboard": "Dashboard", + "stacks": "Stacks", + "app_store": "App Store", + "images": "Docker Images", + "audit": "Audit Log", + "settings": "Instellingen", + "modules": "Modules", + "overview": "Overzicht van je Docker-omgeving", + "running": "Actief", + "stopped": "Gestopt", + "start": "Start", + "stop": "Stop", + "restart": "Herstart", + "update": "Bijwerken", + "remove": "Verwijderen", + "install": "Installeren", + "save": "Opslaan", + "saved": "Opgeslagen", + "cancel": "Annuleren", + "close": "Sluiten", + "search": "Zoeken", + "loading": "Laden…", + "no_results": "Geen resultaten", + "refresh": "Vernieuwen", + "add": "Toevoegen", + "delete": "Verwijderen", + "edit": "Bewerken", + "confirm": "Bevestigen", + "prune": "Opschonen", + "pull": "Pull", + "clone": "Clone", + "sync": "Synchroniseren", + "logs": "Logs", + "backup": "Backup", + "env_editor": ".env bewerken", + "compose_editor": "Compose bewerken", + "paths": "Paden", + "library_dir": "Library map", + "data_dir": "Data map", + "backup_dir": "Backup map", + "theme": "Thema", + "language": "Taal", + "git": "Git", + "docker": "Docker", + "no_stacks": "Geen stacks gevonden", + "no_images": "Geen images gevonden", + "no_repos": "Geen repositories", + "add_repo": "Repository toevoegen", + "repo_name": "Naam", + "repo_url": "Git URL", + "repo_branch": "Branch", + "repo_subdir": "Submap", + "install_stack": "Stack installeren", + "instance_name": "Instantie naam", + "instance_hint": "Wijzig voor meerdere installaties", + "remove_stack": "Stack verwijderen", + "remove_stack_only": "Stop containers + volumes", + "remove_all": "Stop + verwijder alle bestanden", + "prune_images": "Alle ongebruikte images verwijderen?", + "confirm_remove_image": "Image verwijderen?", + "force_remove": "Forceer verwijdering?", + "container_restart": "Container herstarten", + "container_restart_hint": "Herstart de Server Up container", + "container_restarting": "Container wordt herstart…", + "wizard": "Setup Wizard", + "wizard_open": "Wizard openen", + "wizard_reset": "Opnieuw uitvoeren", + "wizard_welcome": "Welkom bij Server Up", + "wizard_welcome_sub": "Deze wizard begeleidt je door de eerste opstart. Alle stappen zijn optioneel.", + "wizard_paths": "Mappen instellen", + "wizard_paths_sub": "Waar worden je Docker-projecten opgeslagen?", + "wizard_git": "Git repositories", + "wizard_git_sub": "Synchroniseer je stacks en modules.", + "wizard_done": "Klaar!", + "wizard_done_sub": "Setup is voltooid. Wijzig alles later via Instellingen.", + "prev": "Vorige", + "next": "Volgende", + "finish": "Afronden", + "ready": "Gereed", + "busy": "Bezig", + "done": "Klaar", + "error": "Fout", + "optional_modules": "Optionele modules", + "module_repos": "Module repositories", + "installed": "GeΓ―nstalleerd", + "available": "Beschikbaar", + "synced": "Gesynchroniseerd", + "not_cloned": "Niet gecloned", + "auto_saved": "Automatisch opgeslagen" +} diff --git a/apps/adguard-home/files/compose.yaml b/apps/adguard-home/files/compose.yaml new file mode 100644 index 0000000..62d06db --- /dev/null +++ b/apps/adguard-home/files/compose.yaml @@ -0,0 +1,12 @@ +services: + << service_name >>: + image: adguard/adguardhome:latest + container_name: << service_name >> + ports: + - "<< port_web >>:3000" + - "<< port_dns >>:53/tcp" + - "<< port_dns >>:53/udp" + volumes: + - << data_dir >>/<< service_name >>/work:/opt/adguardhome/work + - << data_dir >>/<< service_name >>/conf:/opt/adguardhome/conf + restart: unless-stopped diff --git a/apps/adguard-home/template.json b/apps/adguard-home/template.json new file mode 100644 index 0000000..b335b7e --- /dev/null +++ b/apps/adguard-home/template.json @@ -0,0 +1,52 @@ +{ + "kind": "compose", + "metadata": { + "name": "AdGuard Home", + "description": "DNS-level advertentie- en trackerblokkering voor het hele netwerk", + "tags": ["dns", "adblock", "netwerk", "privacy"], + "icon": {"provider": "selfhst", "id": "adguard-home"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "adguard-home", + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + } + ] + }, + { + "title": "Poorten", + "items": [ + { + "name": "port_web", + "type": "int", + "title": "Web poort", + "default": 3000, + "required": true, + "description": "Setup-wizard en webinterface" + }, + { + "name": "port_dns", + "type": "int", + "title": "DNS poort", + "default": 53, + "required": true, + "description": "Standaard DNS poort. Zorg dat poort 53 vrij is (disable systemd-resolved indien nodig)" + } + ] + } + ] +} diff --git a/apps/forgejo/files/compose.yaml b/apps/forgejo/files/compose.yaml new file mode 100644 index 0000000..643e5be --- /dev/null +++ b/apps/forgejo/files/compose.yaml @@ -0,0 +1,17 @@ +services: + << service_name >>: + image: codeberg.org/forgejo/forgejo:latest + container_name: << service_name >> + ports: + - "<< port_web >>:3000" + - "<< port_ssh >>:22" + environment: + - USER_UID=1000 + - USER_GID=1000 + - FORGEJO__server__SSH_PORT=<< port_ssh >> + - TZ=<< timezone >> + volumes: + - << data_dir >>/<< service_name >>/data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + restart: unless-stopped diff --git a/apps/forgejo/template.json b/apps/forgejo/template.json new file mode 100644 index 0000000..5e1dd23 --- /dev/null +++ b/apps/forgejo/template.json @@ -0,0 +1,58 @@ +{ + "kind": "compose", + "metadata": { + "name": "Forgejo", + "description": "Lichtgewicht zelfgehoste Git-server. Actief onderhouden community-fork van Gitea.", + "tags": ["git", "code", "versiebeheer", "ontwikkeling"], + "icon": {"provider": "selfhst", "id": "forgejo"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "forgejo", + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Poorten", + "items": [ + { + "name": "port_web", + "type": "int", + "title": "Web poort", + "default": 3000, + "required": true + }, + { + "name": "port_ssh", + "type": "int", + "title": "SSH poort", + "default": 2222, + "required": true, + "description": "SSH-toegang voor git push/pull. Poort 2222 om conflict met host-SSH te vermijden." + } + ] + } + ] +} diff --git a/apps/immich/files/compose.yaml b/apps/immich/files/compose.yaml new file mode 100644 index 0000000..58cbca4 --- /dev/null +++ b/apps/immich/files/compose.yaml @@ -0,0 +1,46 @@ +services: + << service_name >>: + image: ghcr.io/immich-app/immich-server:release + container_name: << service_name >> + ports: + - "<< port >>:2283" + environment: + - DB_HOSTNAME=<< service_name >>-postgres + - DB_USERNAME=immich + - DB_PASSWORD=<< db_password >> + - DB_DATABASE_NAME=immich + - REDIS_HOSTNAME=<< service_name >>-redis + - TZ=<< timezone >> + volumes: + - << data_dir >>/<< service_name >>/upload:/usr/src/app/upload + depends_on: + - << service_name >>-redis + - << service_name >>-postgres + restart: unless-stopped + + << service_name >>-machine-learning: + image: ghcr.io/immich-app/immich-machine-learning:release + container_name: << service_name >>-machine-learning + volumes: + - << service_name >>_model_cache:/cache + restart: unless-stopped + + << service_name >>-redis: + image: redis:6.2-alpine + container_name: << service_name >>-redis + restart: unless-stopped + + << service_name >>-postgres: + image: tensorchord/pgvecto-rs:pg14-v0.2.0 + container_name: << service_name >>-postgres + environment: + - POSTGRES_USER=immich + - POSTGRES_PASSWORD=<< db_password >> + - POSTGRES_DB=immich + volumes: + - << service_name >>_pgdata:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + << service_name >>_pgdata: + << service_name >>_model_cache: diff --git a/apps/immich/template.json b/apps/immich/template.json new file mode 100644 index 0000000..a744b81 --- /dev/null +++ b/apps/immich/template.json @@ -0,0 +1,60 @@ +{ + "kind": "compose", + "metadata": { + "name": "Immich", + "description": "Zelfgehoste foto- en videobeheer met automatische back-up. Google Photos alternatief.", + "tags": ["fotos", "media", "backup"], + "icon": {"provider": "selfhst", "id": "immich"}, + "version": {"name": "release"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "immich", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Web poort", + "default": 2283, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Upload directory", + "default": "/opt/serverup/appdata", + "required": true, + "description": "Basismap voor foto-uploads en database" + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Database", + "items": [ + { + "name": "db_password", + "type": "str", + "title": "Database wachtwoord", + "default": "immich_db_pass", + "required": true, + "description": "Wachtwoord voor de interne PostgreSQL database", + "config": {"placeholder": "sterk-wachtwoord"} + } + ] + } + ] +} diff --git a/apps/jellyfin/files/compose.yaml b/apps/jellyfin/files/compose.yaml new file mode 100644 index 0000000..97f30fc --- /dev/null +++ b/apps/jellyfin/files/compose.yaml @@ -0,0 +1,13 @@ +services: + << service_name >>: + image: jellyfin/jellyfin:latest + container_name: << service_name >> + ports: + - "<< port >>:8096" + environment: + - TZ=<< timezone >> + volumes: + - << data_dir >>/<< service_name >>/config:/config + - << data_dir >>/<< service_name >>/cache:/cache + - << media_dir >>:/media:ro + restart: unless-stopped diff --git a/apps/jellyfin/template.json b/apps/jellyfin/template.json new file mode 100644 index 0000000..5bcb16d --- /dev/null +++ b/apps/jellyfin/template.json @@ -0,0 +1,59 @@ +{ + "kind": "compose", + "metadata": { + "name": "Jellyfin", + "description": "Gratis en open-source mediaserver voor films, series en muziek. Geen abonnement.", + "tags": ["media", "streaming", "films", "muziek"], + "icon": {"provider": "selfhst", "id": "jellyfin"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "jellyfin", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Web poort", + "default": 8096, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true, + "description": "Basismap voor configuratie en cache" + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Media", + "items": [ + { + "name": "media_dir", + "type": "str", + "title": "Media directory", + "default": "/mnt/media", + "required": true, + "description": "Map met je films, series en muziek (wordt read-only gemount)" + } + ] + } + ] +} diff --git a/apps/n8n/files/compose.yaml b/apps/n8n/files/compose.yaml new file mode 100644 index 0000000..3e9754d --- /dev/null +++ b/apps/n8n/files/compose.yaml @@ -0,0 +1,17 @@ +services: + << service_name >>: + image: n8nio/n8n:latest + container_name: << service_name >> + ports: + - "<< port >>:5678" + environment: + - N8N_HOST=<< webhook_host >> + - N8N_PORT=5678 + - N8N_PROTOCOL=http + - WEBHOOK_URL=http://<< webhook_host >>:<< port >>/ + - GENERIC_TIMEZONE=<< timezone >> + - TZ=<< timezone >> + - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true + volumes: + - << data_dir >>/<< service_name >>/data:/home/node/.n8n + restart: unless-stopped diff --git a/apps/n8n/template.json b/apps/n8n/template.json new file mode 100644 index 0000000..3361263 --- /dev/null +++ b/apps/n8n/template.json @@ -0,0 +1,59 @@ +{ + "kind": "compose", + "metadata": { + "name": "n8n", + "description": "No-code/low-code workflow automatisering. Zapier-alternatief met 400+ integraties.", + "tags": ["automatisering", "workflow", "integratie"], + "icon": {"provider": "selfhst", "id": "n8n"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "n8n", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Web poort", + "default": 5678, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Netwerk", + "items": [ + { + "name": "webhook_host", + "type": "str", + "title": "Webhook hostname", + "default": "localhost", + "required": false, + "description": "Hostname of IP van je server voor webhook-URLs. Gebruik je domeinnaam als je een reverse proxy hebt.", + "config": {"placeholder": "mijn-server.nl"} + } + ] + } + ] +} diff --git a/apps/nextcloud-aio/files/compose.yaml b/apps/nextcloud-aio/files/compose.yaml new file mode 100644 index 0000000..b01bcad --- /dev/null +++ b/apps/nextcloud-aio/files/compose.yaml @@ -0,0 +1,20 @@ +services: + << service_name >>: + image: nextcloud/all-in-one:latest + init: true + container_name: << service_name >> + ports: + - "<< port_admin >>:8080" + environment: + - APACHE_PORT=<< port_nextcloud >> + - APACHE_IP_BINDING=0.0.0.0 + - NEXTCLOUD_DATADIR=<< data_dir >>/<< service_name >>/ncdata + - TZ=<< timezone >> + volumes: + - << service_name >>_config:/mnt/docker-aio-config + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + +volumes: + << service_name >>_config: + name: << service_name >>_config diff --git a/apps/nextcloud-aio/template.json b/apps/nextcloud-aio/template.json new file mode 100644 index 0000000..51f1492 --- /dev/null +++ b/apps/nextcloud-aio/template.json @@ -0,0 +1,61 @@ +{ + "kind": "compose", + "metadata": { + "name": "Nextcloud AIO", + "description": "Nextcloud All-in-One: één container beheert automatisch alle Nextcloud-componenten.", + "tags": ["bestanden", "cloud", "productiviteit", "samenwerken"], + "icon": {"provider": "selfhst", "id": "nextcloud"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "nextcloud-aio-mastercontainer", + "required": true, + "description": "AIO beheert zelf extra containers. Naam is normaal nextcloud-aio-mastercontainer." + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true, + "description": "Basismap voor Nextcloud-bestanden" + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Poorten", + "items": [ + { + "name": "port_admin", + "type": "int", + "title": "Admin poort (HTTPS)", + "default": 8080, + "required": true, + "description": "AIO beheerpaneel via https://server-ip:8080 β€” passphrase staat in de logs" + }, + { + "name": "port_nextcloud", + "type": "int", + "title": "Nextcloud poort", + "default": 11000, + "required": true, + "description": "Nextcloud HTTP-poort (zet een reverse proxy voor HTTPS)" + } + ] + } + ] +} diff --git a/apps/nginx-proxy-manager/files/compose.yaml b/apps/nginx-proxy-manager/files/compose.yaml new file mode 100644 index 0000000..0ead0ac --- /dev/null +++ b/apps/nginx-proxy-manager/files/compose.yaml @@ -0,0 +1,12 @@ +services: + << service_name >>: + image: jc21/nginx-proxy-manager:latest + container_name: << service_name >> + ports: + - "<< port_http >>:80" + - "<< port_https >>:443" + - "<< port_admin >>:81" + volumes: + - << data_dir >>/<< service_name >>/data:/data + - << data_dir >>/<< service_name >>/letsencrypt:/etc/letsencrypt + restart: unless-stopped diff --git a/apps/nginx-proxy-manager/template.json b/apps/nginx-proxy-manager/template.json new file mode 100644 index 0000000..0493aaf --- /dev/null +++ b/apps/nginx-proxy-manager/template.json @@ -0,0 +1,60 @@ +{ + "kind": "compose", + "metadata": { + "name": "Nginx Proxy Manager", + "description": "Reverse proxy met automatische SSL-certificaten en eenvoudige webinterface", + "tags": ["proxy", "ssl", "netwerk"], + "icon": {"provider": "selfhst", "id": "nginx-proxy-manager"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "nginx-proxy-manager", + "required": true, + "description": "Naam van de Docker container" + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true, + "description": "Basismap voor persistente data" + } + ] + }, + { + "title": "Poorten", + "items": [ + { + "name": "port_http", + "type": "int", + "title": "HTTP poort", + "default": 80, + "required": true + }, + { + "name": "port_https", + "type": "int", + "title": "HTTPS poort", + "default": 443, + "required": true + }, + { + "name": "port_admin", + "type": "int", + "title": "Admin poort", + "default": 81, + "required": true, + "description": "Beheerpaneel β€” standaard login: admin@example.com / changeme" + } + ] + } + ] +} diff --git a/apps/paperless-ngx/files/compose.yaml b/apps/paperless-ngx/files/compose.yaml new file mode 100644 index 0000000..f87b054 --- /dev/null +++ b/apps/paperless-ngx/files/compose.yaml @@ -0,0 +1,41 @@ +services: + << service_name >>-broker: + image: redis:7-alpine + container_name: << service_name >>-broker + restart: unless-stopped + + << service_name >>-db: + image: postgres:16-alpine + container_name: << service_name >>-db + environment: + - POSTGRES_DB=paperless + - POSTGRES_USER=paperless + - POSTGRES_PASSWORD=<< db_password >> + volumes: + - << service_name >>_dbdata:/var/lib/postgresql/data + restart: unless-stopped + + << service_name >>: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + container_name: << service_name >> + ports: + - "<< port >>:8000" + environment: + - PAPERLESS_REDIS=redis://<< service_name >>-broker:6379 + - PAPERLESS_DBHOST=<< service_name >>-db + - PAPERLESS_DBPASS=<< db_password >> + - PAPERLESS_SECRET_KEY=<< secret_key >> + - PAPERLESS_TIME_ZONE=<< timezone >> + - PAPERLESS_OCR_LANGUAGE=<< ocr_language >> + volumes: + - << data_dir >>/<< service_name >>/data:/usr/src/paperless/data + - << data_dir >>/<< service_name >>/media:/usr/src/paperless/media + - << data_dir >>/<< service_name >>/export:/usr/src/paperless/export + - << data_dir >>/<< service_name >>/consume:/usr/src/paperless/consume + depends_on: + - << service_name >>-broker + - << service_name >>-db + restart: unless-stopped + +volumes: + << service_name >>_dbdata: diff --git a/apps/paperless-ngx/template.json b/apps/paperless-ngx/template.json new file mode 100644 index 0000000..be4fb97 --- /dev/null +++ b/apps/paperless-ngx/template.json @@ -0,0 +1,80 @@ +{ + "kind": "compose", + "metadata": { + "name": "Paperless-ngx", + "description": "Digitaal documentbeheer met OCR, slimme tags en zoekfunctie.", + "tags": ["documenten", "ocr", "archief", "productiviteit"], + "icon": {"provider": "selfhst", "id": "paperless-ngx"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "paperless-ngx", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Web poort", + "default": 8010, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + }, + { + "name": "timezone", + "type": "str", + "title": "Tijdzone", + "default": "Europe/Amsterdam", + "required": true + } + ] + }, + { + "title": "Beveiliging", + "items": [ + { + "name": "secret_key", + "type": "str", + "title": "Secret key", + "default": "", + "required": true, + "description": "Willekeurige lange string voor sessiebeveiliging", + "config": {"placeholder": "minimaal-50-willekeurige-tekens"} + }, + { + "name": "db_password", + "type": "str", + "title": "Database wachtwoord", + "default": "paperless_db_pass", + "required": true, + "config": {"placeholder": "sterk-wachtwoord"} + } + ] + }, + { + "title": "OCR", + "items": [ + { + "name": "ocr_language", + "type": "str", + "title": "OCR taal", + "default": "nld+eng", + "required": false, + "description": "Tesseract taalcodes, bijv. nld+eng voor Nederlands en Engels" + } + ] + } + ] +} diff --git a/apps/uptime-kuma/files/compose.yaml b/apps/uptime-kuma/files/compose.yaml new file mode 100644 index 0000000..e9027da --- /dev/null +++ b/apps/uptime-kuma/files/compose.yaml @@ -0,0 +1,9 @@ +services: + << service_name >>: + image: louislam/uptime-kuma:1 + container_name: << service_name >> + ports: + - "<< port >>:3001" + volumes: + - << data_dir >>/<< service_name >>/data:/app/data + restart: unless-stopped diff --git a/apps/uptime-kuma/template.json b/apps/uptime-kuma/template.json new file mode 100644 index 0000000..dd3978b --- /dev/null +++ b/apps/uptime-kuma/template.json @@ -0,0 +1,38 @@ +{ + "kind": "compose", + "metadata": { + "name": "Uptime Kuma", + "description": "Mooie en eenvoudige uptime-monitor voor al je services en websites.", + "tags": ["monitoring", "uptime", "alerting"], + "icon": {"provider": "selfhst", "id": "uptime-kuma"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "uptime-kuma", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Web poort", + "default": 3001, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + } + ] + } + ] +} diff --git a/apps/vaultwarden/files/compose.yaml b/apps/vaultwarden/files/compose.yaml new file mode 100644 index 0000000..53e92b2 --- /dev/null +++ b/apps/vaultwarden/files/compose.yaml @@ -0,0 +1,15 @@ +services: + << service_name >>: + image: vaultwarden/server:latest + container_name: << service_name >> + ports: + - "<< port >>:80" + environment: + - WEBSOCKET_ENABLED=true + - SIGNUPS_ALLOWED=<< signups_allowed | lower >> +<% if admin_token %> + - ADMIN_TOKEN=<< admin_token >> +<% endif %> + volumes: + - << data_dir >>/<< service_name >>/data:/data + restart: unless-stopped diff --git a/apps/vaultwarden/template.json b/apps/vaultwarden/template.json new file mode 100644 index 0000000..c4ffe99 --- /dev/null +++ b/apps/vaultwarden/template.json @@ -0,0 +1,59 @@ +{ + "kind": "compose", + "metadata": { + "name": "Vaultwarden", + "description": "Lichtgewicht Bitwarden-compatibele wachtwoordkluis. Werkt met alle Bitwarden-apps.", + "tags": ["wachtwoorden", "beveiliging", "privacy"], + "icon": {"provider": "selfhst", "id": "vaultwarden"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + { + "name": "service_name", + "type": "str", + "title": "Servicenaam", + "default": "vaultwarden", + "required": true + }, + { + "name": "port", + "type": "int", + "title": "Poort", + "default": 8222, + "required": true + }, + { + "name": "data_dir", + "type": "str", + "title": "Data directory", + "default": "/opt/serverup/appdata", + "required": true + } + ] + }, + { + "title": "Instellingen", + "items": [ + { + "name": "signups_allowed", + "type": "bool", + "title": "Registratie toestaan", + "default": true, + "description": "Schakel uit na aanmaken van je account voor extra beveiliging" + }, + { + "name": "admin_token", + "type": "str", + "title": "Admin token", + "default": "", + "required": false, + "description": "Willekeurige geheime string voor het /admin paneel. Laat leeg om admin uit te schakelen.", + "config": {"placeholder": "willekeurige-geheime-string"} + } + ] + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d7293d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + server-up: + build: . + image: server-up:latest + container_name: server-up + hostname: server-up + user: "0:0" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - su-data:/data + # Same-path mount: pad binnen container = pad op host + # Wijzig BASE_DIR in .env om alle paden te verplaatsen + - ${BASE_DIR:-/opt/serverup}:${BASE_DIR:-/opt/serverup} + ports: + - "${PORT:-5000}:5000" + environment: + - PORT=5000 + - HOME=/root + - SU_CONFIG=/data/config.json + - SU_AUDIT=/data/audit.db + - SU_GIT_CACHE=/data/git + - SU_CONTAINER=server-up + # Voeg eigen app-repos toe zonder wizard. Eenmalig opgeslagen in config.json. + # Format: JSON-array van repo-objecten. Velden: id, url, branch, subdir, name, token (optioneel). + # Voorbeeld: + # - SU_BOOT_REPOS=[{"id":"mijn-apps","name":"Mijn Apps","url":"https://github.com/gebruiker/mijn-apps.git","branch":"main","subdir":"apps"}] + # Debug-endpoints inschakelen (api/debug/*): + # - SU_DEBUG=1 + restart: unless-stopped + +volumes: + su-data: diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29