diff --git a/README.md b/README.md index f502797..c02810b 100644 --- a/README.md +++ b/README.md @@ -91,4 +91,87 @@ Die Nginx-Vorlagen liegen unter [nginx](nginx): - [nginx/gitea.http.conf.template](nginx/gitea.http.conf.template) - [nginx/gitea.https.conf.template](nginx/gitea.https.conf.template) - [nginx/website.http.conf.template](nginx/website.http.conf.template) -- [nginx/website.https.conf.template](nginx/website.https.conf.template) \ No newline at end of file +- [nginx/website.https.conf.template](nginx/website.https.conf.template) + +## Admin: Schul-Instanzen fuer Subdomains + +Die Website hat jetzt eine Admin-Seite zum Starten von Inventarsystem-Instanzen pro Schule: + +- Route: `/admin/instances` +- Admin-Menue: `Schul-Instanzen` +- Ziel-Repository (Standard): `https://github.com/AIIrondev/legendary-octo-garbanzo` + +### Ablauf + +1. Admin gibt Schulname (und optional Subdomain) ein. +2. Admin weist die Instanz einem konkreten Nutzer zu. +3. Admin wählt eine Version (Image-Tag) pro Nutzer/Instanz. +4. Admin kann das Bibliotheksmodul für die Instanz aktivieren/deaktivieren. +5. Backend ruft `provision_instance.sh` auf. +6. Skript klont/aktualisiert die Instanz unter dem Basisverzeichnis. +7. Skript startet die Instanz (`start.sh --no-cron`) und versucht eine Nginx-Site fuer die Subdomain anzulegen. +8. Status wird in MongoDB (`school_instances`) gespeichert und im Admin-Fenster angezeigt. + +Hinweis: Die Zuweisung ist auf `eine Instanz pro Nutzer` begrenzt. + +### Wichtige Environment-Variablen (Website) + +- `INSTANCE_REPO_URL` (default: `https://github.com/AIIrondev/legendary-octo-garbanzo`) +- `INSTANCE_PARENT_DOMAIN` (default: `meine-domain`) +- `INSTANCE_BASE_DIR` (default: `/opt/inventarsystem-instances`) +- `INSTANCE_PROVISION_SCRIPT` (default: `Website/provision_instance.sh`) + +### Berechtigungen + +Das Provisioning-Skript fuehrt Docker- und Nginx-Operationen aus. Der Prozess, der die Website ausfuehrt, braucht daher: + +- Zugriff auf `docker` (Docker Socket / Gruppe) +- Schreibrechte auf `/etc/nginx/sites-available` und `/etc/nginx/sites-enabled` +- Recht zum `nginx -t` und `nginx -s reload` + +Wenn diese Rechte fehlen, wird die Instanz ggf. trotzdem gestartet, aber die Nginx-Verknuepfung muss manuell erfolgen. + +### Nutzerfenster: Eigene Instanz verwalten + +Eingeloggte Nutzer haben im Nutzerbereich jetzt den Punkt `Meine Instanz`. + +- Route: `/my/instance` +- Funktion: Nur zugewiesene Instanz einsehen und nutzen (read-only) +- Zuordnung: ueber `owner_username` + +## Admin: System-Tools + +Neue Admin-Seite fuer Betriebsaufgaben: + +- Route: `/admin/system` +- Funktionen: + - Core-Services neu starten (`website`, `mongodb`) + - Core-Logs als Datei herunterladen + - Pro Instanz: Backup, Update, Restart + - Pro Instanz: Logs als Datei herunterladen + +## Betriebsmodi: Development und Production + +Im Ordner `Website/` gibt es zwei Launcher-Skripte: + +- `./launch_dev.sh` + - setzt `INSTANCE_TLS_MODE=development` + - erzeugt pro Subdomain self-signed Zertifikate (wenn moeglich) + - setzt `SESSION_COOKIE_SECURE=0` + +- `./launch_prod.sh` + - setzt `INSTANCE_TLS_MODE=production` + - nutzt Wildcard-Zertifikat ueber: + - `INSTANCE_WILDCARD_CERT_FILE` + - `INSTANCE_WILDCARD_KEY_FILE` + - setzt `SESSION_COOKIE_SECURE=1` + +Beispiel Production-Start mit eigener Domain: + +```bash +cd Website +export INSTANCE_PARENT_DOMAIN=example.de +export INSTANCE_WILDCARD_CERT_FILE=/etc/nginx/certs/wildcard.example.de.crt +export INSTANCE_WILDCARD_KEY_FILE=/etc/nginx/certs/wildcard.example.de.key +./launch_prod.sh +``` \ No newline at end of file diff --git a/Website/Dockerfile b/Website/Dockerfile index e04fc9c..859792c 100644 --- a/Website/Dockerfile +++ b/Website/Dockerfile @@ -1,49 +1,43 @@ -FROM python:3.12-slim AS builder +FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app -ARG NUITKA_JOBS=1 -ARG NUITKA_LOW_MEMORY=1 - RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential patchelf \ + && apt-get install -y --no-install-recommends \ + git \ + iproute2 \ + docker.io \ + curl \ + openssl \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* +RUN set -eu; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) compose_arch="x86_64" ;; \ + arm64) compose_arch="aarch64" ;; \ + *) echo "Unsupported architecture for compose plugin: $arch"; exit 1 ;; \ + esac; \ + curl -fsSL "https://download.docker.com/linux/static/stable/${compose_arch}/docker-27.1.1.tgz" \ + -o /tmp/docker.tgz; \ + tar -xzf /tmp/docker.tgz -C /tmp; \ + install -m 0755 /tmp/docker/docker /usr/local/bin/docker; \ + rm -rf /tmp/docker /tmp/docker.tgz; \ + mkdir -p /usr/libexec/docker/cli-plugins; \ + curl -fsSL "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-${compose_arch}" \ + -o /usr/libexec/docker/cli-plugins/docker-compose; \ + chmod +x /usr/libexec/docker/cli-plugins/docker-compose + COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt \ - && pip install --no-cache-dir nuitka ordered-set zstandard +RUN pip install --no-cache-dir -r requirements.txt COPY . . - -# Build a standalone binary with all required templates/static/data assets. -# Defaults are tuned for lower RAM pressure to avoid host crashes. -RUN set -eu; \ - EXTRA_FLAGS=""; \ - if [ "$NUITKA_LOW_MEMORY" = "1" ]; then \ - EXTRA_FLAGS="--low-memory --lto=no"; \ - fi; \ - python -m nuitka \ - --standalone \ - --follow-imports \ - --assume-yes-for-downloads \ - --jobs="$NUITKA_JOBS" \ - --remove-output \ - --include-data-dir=templates=templates \ - --include-data-dir=static=static \ - --include-data-dir=data=data \ - --output-dir=build \ - $EXTRA_FLAGS \ - main.py - -FROM python:3.12-slim AS runtime - -WORKDIR /app - -COPY --from=builder /app/build/main.dist ./ +RUN chmod +x /app/provision_instance.sh EXPOSE 4999 -CMD ["./main.bin"] +CMD ["python", "main.py"] diff --git a/Website/data/users.json b/Website/data/users.json index 7c28a1d..e69de29 100644 --- a/Website/data/users.json +++ b/Website/data/users.json @@ -1,9 +0,0 @@ -[ - { - "username": "Aiirondev", - "display_name": "Maximilian Gr\u00fcndinger", - "password_hash": "scrypt:32768:8:1$nfN2TTigH6GNrZig$548ac55b59b2c10f2730a4a45b4140c84eba33e2719f95175c05ab1f3a157959ff7a5d11c3d38bd2ea172b0143fa3a0e0e410b8bfcc81b8d91cfbbfc12b30399", - "is_admin": true, - "created_at": "2026-03-23T16:05:42Z" - } -] \ No newline at end of file diff --git a/Website/docker-compose.yml b/Website/docker-compose.yml index 4bd62af..ae6cca3 100644 --- a/Website/docker-compose.yml +++ b/Website/docker-compose.yml @@ -16,9 +16,6 @@ services: build: context: . dockerfile: Dockerfile - args: - NUITKA_JOBS: ${NUITKA_JOBS:-1} - NUITKA_LOW_MEMORY: ${NUITKA_LOW_MEMORY:-1} restart: unless-stopped depends_on: mongodb: @@ -26,8 +23,21 @@ services: environment: MONGO_URI: mongodb://mongodb:27017 MONGO_DB_NAME: Invario_Website - SESSION_COOKIE_SECURE: "0" + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} JWT_SECRET_KEY: change-this-in-production + INSTANCE_PROVISION_SCRIPT: /app/provision_instance.sh + INSTANCE_BASE_DIR: /opt/inventarsystem-instances + INSTANCE_PARENT_DOMAIN: ${INSTANCE_PARENT_DOMAIN:-meine-domain} + INSTANCE_REPO_URL: ${INSTANCE_REPO_URL:-https://github.com/AIIrondev/legendary-octo-garbanzo} + INSTANCE_TLS_MODE: ${INSTANCE_TLS_MODE:-development} + INSTANCE_WILDCARD_CERT_FILE: ${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt} + INSTANCE_WILDCARD_KEY_FILE: ${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/inventarsystem-instances:/opt/inventarsystem-instances + - /etc/nginx/sites-available:/etc/nginx/sites-available + - /etc/nginx/sites-enabled:/etc/nginx/sites-enabled + - /etc/nginx/certs:/etc/nginx/certs ports: - "4999:4999" diff --git a/Website/launch_dev.sh b/Website/launch_dev.sh new file mode 100755 index 0000000..62bbc93 --- /dev/null +++ b/Website/launch_dev.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +export SESSION_COOKIE_SECURE="0" +export INSTANCE_TLS_MODE="development" +export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}" + +echo "Launching DEVELOPMENT stack" +echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE" +echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE" +echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN" + +docker compose build website +docker compose up -d + +if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then + "$SCRIPT_DIR/sync_dev_hosts.sh" || true +fi + +echo "Development stack is running on http://localhost:4999" +echo "Provisioning creates self-signed certs per subdomain when needed." diff --git a/Website/launch_prod.sh b/Website/launch_prod.sh new file mode 100755 index 0000000..ae8c64d --- /dev/null +++ b/Website/launch_prod.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +export SESSION_COOKIE_SECURE="1" +export INSTANCE_TLS_MODE="production" +export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}" +export INSTANCE_WILDCARD_CERT_FILE="${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt}" +export INSTANCE_WILDCARD_KEY_FILE="${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key}" + +if [[ ! -f "$INSTANCE_WILDCARD_CERT_FILE" || ! -f "$INSTANCE_WILDCARD_KEY_FILE" ]]; then + echo "Error: Wildcard certificate missing for production mode" + echo " CERT: $INSTANCE_WILDCARD_CERT_FILE" + echo " KEY: $INSTANCE_WILDCARD_KEY_FILE" + exit 1 +fi + +echo "Launching PRODUCTION stack" +echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE" +echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE" +echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN" +echo " INSTANCE_WILDCARD_CERT_FILE=$INSTANCE_WILDCARD_CERT_FILE" +echo " INSTANCE_WILDCARD_KEY_FILE=$INSTANCE_WILDCARD_KEY_FILE" + +docker compose build website +docker compose up -d + +echo "Production stack is running on http://localhost:4999" +echo "Provisioning expects wildcard certificate for all subdomains." diff --git a/Website/main.py b/Website/main.py index e2d2b79..2efa5c1 100644 --- a/Website/main.py +++ b/Website/main.py @@ -3,6 +3,10 @@ from flask import Flask, render_template, request, jsonify, flash, redirect, url import os import json import calendar +import re +import subprocess +import hashlib +import shutil from datetime import timedelta, datetime, date from functools import wraps from io import BytesIO @@ -43,6 +47,23 @@ INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices") TEAM_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "team") MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017") MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website") +INSTANCE_REPO_URL = os.environ.get("INSTANCE_REPO_URL", "https://github.com/AIIrondev/legendary-octo-garbanzo") +INSTANCE_PARENT_DOMAIN = os.environ.get("INSTANCE_PARENT_DOMAIN", "meine-domain") +INSTANCE_BASE_DIR = os.environ.get("INSTANCE_BASE_DIR", "/opt/inventarsystem-instances") +INSTANCE_TLS_MODE = os.environ.get("INSTANCE_TLS_MODE", "development") +INSTANCE_VERSION_OPTIONS = os.environ.get("INSTANCE_VERSION_OPTIONS", "latest,v0.3.1") +INSTANCE_WILDCARD_CERT_FILE = os.environ.get( + "INSTANCE_WILDCARD_CERT_FILE", + "/etc/nginx/certs/wildcard.meine-domain.crt", +) +INSTANCE_WILDCARD_KEY_FILE = os.environ.get( + "INSTANCE_WILDCARD_KEY_FILE", + "/etc/nginx/certs/wildcard.meine-domain.key", +) +INSTANCE_PROVISION_SCRIPT = os.environ.get( + "INSTANCE_PROVISION_SCRIPT", + os.path.abspath(os.path.join(BASE_DIR, "provision_instance.sh")), +) def _issue_access_token() -> str: @@ -137,6 +158,571 @@ def _sanitize_text(text: str, max_length: int = 255) -> str: return text +def _slugify_subdomain(value: str) -> str: + cleaned = (value or "").strip().lower() + cleaned = cleaned.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + cleaned = re.sub(r"[^a-z0-9-]+", "-", cleaned) + cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-") + return cleaned[:63] + + +def _is_valid_subdomain(value: str) -> bool: + if not value or len(value) < 3 or len(value) > 63: + return False + return bool(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])", value)) + + +def _parse_key_value_output(raw: str) -> dict: + parsed = {} + for line in (raw or "").splitlines(): + row = line.strip() + if not row or "=" not in row: + continue + key, value = row.split("=", 1) + parsed[key.strip()] = value.strip() + return parsed + + +def _parse_instance_version_options() -> list[str]: + values = [] + for item in (INSTANCE_VERSION_OPTIONS or "").split(","): + token = _sanitize_text(item or "", 80) + if token and token not in values: + values.append(token) + if not values: + values = ["latest"] + return values + + +def _list_school_instances() -> list: + rows = [] + client = None + try: + client, col = _get_collection("school_instances") + rows = list(col.find().sort([("updated_at", -1), ("created_at", -1)])) + except PyMongoError: + return [] + finally: + if client: + client.close() + + normalized = [] + for row in rows: + normalized.append( + { + "id": str(row.get("_id") or ""), + "school_name": _sanitize_text(row.get("school_name") or "", 120), + "owner_username": _sanitize_text(row.get("owner_username") or "", 80), + "subdomain": _sanitize_text(row.get("subdomain") or "", 63), + "domain": _sanitize_text(row.get("domain") or "", 190), + "https_port": int(row.get("https_port") or 0), + "instance_dir": _sanitize_text(row.get("instance_dir") or "", 300), + "app_image_tag": _sanitize_text(row.get("app_image_tag") or "latest", 80), + "library_enabled": bool(row.get("library_enabled", False)), + "status": _sanitize_text(row.get("status") or "Unbekannt", 40), + "nginx_status": _sanitize_text(row.get("nginx_status") or "unbekannt", 80), + "last_message": _sanitize_text(row.get("last_message") or "", 500), + "updated_at": row.get("updated_at") or "", + } + ) + return normalized + + +def _list_available_instance_users() -> list: + users = _list_users_for_admin() + instances = _list_school_instances() + assigned = { + _sanitize_text(item.get("owner_username") or "", 80).lower() + for item in instances + if _sanitize_text(item.get("owner_username") or "", 80) + } + return [u for u in users if _sanitize_text(u.get("username") or "", 80).lower() not in assigned] + + +def _list_instances_grouped_by_owner() -> dict[str, list[dict]]: + grouped: dict[str, list[dict]] = {} + for item in _list_school_instances(): + owner = _sanitize_text(item.get("owner_username") or "", 80) + domain = _sanitize_text(item.get("domain") or "", 190) + subdomain = _sanitize_text(item.get("subdomain") or "", 63) + if not owner or not domain: + continue + grouped.setdefault(owner, []).append( + { + "domain": domain, + "subdomain": subdomain, + "status": _sanitize_text(item.get("status") or "", 40), + } + ) + return grouped + + +def _upsert_school_instance(data: dict) -> None: + subdomain = _sanitize_text(data.get("subdomain") or "", 63) + if not subdomain: + return + + payload = { + "school_name": _sanitize_text(data.get("school_name") or "", 120), + "owner_username": _sanitize_text(data.get("owner_username") or "", 80), + "subdomain": subdomain, + "domain": _sanitize_text(data.get("domain") or "", 190), + "https_port": int(data.get("https_port") or 0), + "instance_dir": _sanitize_text(data.get("instance_dir") or "", 300), + "app_image_tag": _sanitize_text(data.get("app_image_tag") or "latest", 80), + "library_enabled": bool(data.get("library_enabled", False)), + "status": _sanitize_text(data.get("status") or "Unbekannt", 40), + "nginx_status": _sanitize_text(data.get("nginx_status") or "unbekannt", 80), + "last_message": _sanitize_text(data.get("last_message") or "", 500), + "updated_at": _utc_now_iso(), + } + + client = None + try: + client, col = _get_collection("school_instances") + col.update_one( + {"subdomain": subdomain}, + {"$set": payload, "$setOnInsert": {"created_at": _utc_now_iso()}}, + upsert=True, + ) + except PyMongoError: + return + finally: + if client: + client.close() + + +def _run_instance_provision( + action: str, + school_name: str, + subdomain: str, + app_image_tag: str = "latest", + library_enabled: bool = False, +) -> tuple[bool, str, dict]: + script_path = (INSTANCE_PROVISION_SCRIPT or "").strip() + if not script_path: + return False, "INSTANCE_PROVISION_SCRIPT ist nicht gesetzt.", {} + + if not os.path.isfile(script_path): + return False, f"Provisioning-Skript nicht gefunden: {script_path}", {} + + if not os.access(script_path, os.X_OK): + return False, f"Provisioning-Skript ist nicht ausführbar: {script_path}", {} + + command = [ + script_path, + "--action", + action, + "--repo", + INSTANCE_REPO_URL, + "--base-dir", + INSTANCE_BASE_DIR, + "--domain", + INSTANCE_PARENT_DOMAIN, + "--tls-mode", + INSTANCE_TLS_MODE, + "--wildcard-cert-file", + INSTANCE_WILDCARD_CERT_FILE, + "--wildcard-key-file", + INSTANCE_WILDCARD_KEY_FILE, + "--app-image-tag", + _sanitize_text(app_image_tag or "latest", 80), + "--library-enabled", + "1" if library_enabled else "0", + "--school-name", + school_name, + "--subdomain", + subdomain, + ] + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=900, + check=False, + env={**os.environ, "LC_ALL": "C.UTF-8"}, + ) + except subprocess.TimeoutExpired: + return False, "Provisioning-Zeitlimit erreicht (15 Minuten).", {} + except Exception as exc: + return False, f"Provisioning konnte nicht gestartet werden: {exc}", {} + + output = _parse_key_value_output(result.stdout) + message = output.get("MESSAGE") or output.get("ERROR") or (result.stderr or "").strip() + + if result.returncode != 0: + if not message: + message = "Provisioning fehlgeschlagen. Details im Server-Log prüfen." + return False, message, output + + return True, message or "Instanz erfolgreich gestartet.", output + + +def _instance_dir_path(subdomain: str) -> str | None: + key = _sanitize_text(subdomain or "", 63) + if not _is_valid_subdomain(key): + return None + + target = os.path.abspath(os.path.join(INSTANCE_BASE_DIR, key)) + base_abs = os.path.abspath(INSTANCE_BASE_DIR) + if not target.startswith(base_abs + os.sep): + return None + return target + + +def _resolve_instance_dir(subdomain: str) -> str | None: + key = _sanitize_text(subdomain or "", 63) + if not _is_valid_subdomain(key): + return None + + target = os.path.abspath(os.path.join(INSTANCE_BASE_DIR, key)) + base_abs = os.path.abspath(INSTANCE_BASE_DIR) + if not target.startswith(base_abs + os.sep): + return None + if not os.path.isdir(target): + return None + return target + + +def _run_command(command: list[str], cwd: str | None = None, timeout: int = 900) -> tuple[bool, str]: + try: + result = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + env={**os.environ, "LC_ALL": "C.UTF-8"}, + ) + except subprocess.TimeoutExpired: + return False, "Befehl hat das Zeitlimit erreicht." + except Exception as exc: + return False, f"Befehl konnte nicht ausgeführt werden: {exc}" + + output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() + if result.returncode != 0: + return False, output or "Befehl ist fehlgeschlagen." + return True, output or "OK" + + +def _tail_output(text: str, lines: int = 24) -> str: + rows = [line for line in (text or "").splitlines() if line.strip()] + if not rows: + return "Keine Ausgabe" + return "\n".join(rows[-lines:]) + + +def _collect_core_logs() -> tuple[bool, str]: + compose_file = os.path.join(BASE_DIR, "docker-compose.yml") + command = [ + "docker", + "compose", + "-f", + compose_file, + "logs", + "--no-color", + "--tail", + "500", + "website", + "mongodb", + ] + return _run_command(command, cwd=BASE_DIR, timeout=180) + + +def _collect_instance_logs(subdomain: str) -> tuple[bool, str]: + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + return False, "Instanzverzeichnis nicht gefunden." + + command = [ + "docker", + "compose", + "--env-file", + ".docker-build.env", + "logs", + "--no-color", + "--tail", + "500", + ] + return _run_command(command, cwd=instance_dir, timeout=180) + + +def _set_instance_library_enabled(instance_dir: str, enabled: bool) -> tuple[bool, str]: + config_path = os.path.join(instance_dir, "config.json") + if not os.path.isfile(config_path): + return False, "config.json nicht gefunden." + + try: + with open(config_path, "r", encoding="utf-8") as handle: + config = json.load(handle) + except Exception as exc: + return False, f"config.json konnte nicht gelesen werden: {exc}" + + modules = config.get("modules") + if not isinstance(modules, dict): + modules = {} + config["modules"] = modules + + library = modules.get("library") + if not isinstance(library, dict): + library = {} + modules["library"] = library + + library["enabled"] = bool(enabled) + + try: + with open(config_path, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=2, ensure_ascii=False) + handle.write("\n") + except Exception as exc: + return False, f"config.json konnte nicht geschrieben werden: {exc}" + + return True, "Bibliothekseinstellung gespeichert." + + +def _restart_instance_stack(instance_dir: str) -> tuple[bool, str]: + restart_cmd = [ + "docker", + "compose", + "--env-file", + ".docker-build.env", + "restart", + "app", + "nginx", + "mongodb", + ] + up_cmd = [ + "docker", + "compose", + "--env-file", + ".docker-build.env", + "up", + "-d", + "--remove-orphans", + ] + + # restart may fail for stopped services; ensure desired state with up -d afterwards. + _run_command(restart_cmd, cwd=instance_dir, timeout=420) + + ok, output = _run_command(up_cmd, cwd=instance_dir, timeout=900) + if ok: + return True, output + + # Missing app image is a common restart failure after tag changes. + if "local app image not found" in (output or "").lower() and os.path.isfile(os.path.join(instance_dir, "update.sh")): + upd_ok, upd_out = _run_command(["bash", "./update.sh"], cwd=instance_dir, timeout=2400) + if upd_ok: + ok2, out2 = _run_command(up_cmd, cwd=instance_dir, timeout=900) + if ok2: + return True, f"{upd_out}\n{out2}".strip() + return False, out2 + return False, upd_out + + return False, output + + +def _delete_instance_stack(subdomain: str) -> tuple[bool, str]: + target_dir = _instance_dir_path(subdomain) + if not target_dir: + return False, "Ungültige Subdomain für Löschung." + + details: list[str] = [] + + if os.path.isdir(target_dir): + compose_file = os.path.join(target_dir, "docker-compose.yml") + if os.path.isfile(compose_file): + down_cmd = [ + "docker", + "compose", + "--env-file", + ".docker-build.env", + "down", + "--remove-orphans", + "--volumes", + "--timeout", + "40", + ] + down_ok, down_out = _run_command(down_cmd, cwd=target_dir, timeout=900) + if not down_ok: + return False, f"Docker-Stack konnte nicht gestoppt werden.\n{_tail_output(down_out, 12)}" + details.append("Docker-Stack gestoppt und Volumes entfernt.") + + try: + shutil.rmtree(target_dir) + details.append("Instanzverzeichnis gelöscht.") + except Exception as exc: + return False, f"Instanzverzeichnis konnte nicht gelöscht werden: {exc}" + else: + details.append("Instanzverzeichnis war bereits entfernt.") + + nginx_sites_available = "/etc/nginx/sites-available" + nginx_sites_enabled = "/etc/nginx/sites-enabled" + site_name = f"inventarsystem-{subdomain}.conf" + avail_file = os.path.join(nginx_sites_available, site_name) + enabled_file = os.path.join(nginx_sites_enabled, site_name) + + removed_nginx_file = False + for path in (enabled_file, avail_file): + try: + if os.path.lexists(path): + os.remove(path) + removed_nginx_file = True + except Exception: + continue + + if removed_nginx_file: + if os.path.isfile("/usr/sbin/nginx") or os.path.isfile("/usr/bin/nginx"): + test_ok, _ = _run_command(["nginx", "-t"], timeout=60) + if test_ok: + _run_command(["nginx", "-s", "reload"], timeout=60) + details.append("Nginx-Site entfernt.") + + return True, " ".join(details) if details else "Instanz gelöscht." + + +def _delete_school_instance(subdomain: str) -> bool: + key = _sanitize_text(subdomain or "", 63) + if not key: + return False + + client = None + try: + client, col = _get_collection("school_instances") + result = col.delete_one({"subdomain": key}) + return result.deleted_count > 0 + except PyMongoError: + return False + finally: + if client: + client.close() + + +def _instance_db_name(instance_dir: str) -> str: + config_path = os.path.join(instance_dir, "config.json") + try: + with open(config_path, "r", encoding="utf-8") as handle: + cfg = json.load(handle) + value = ((cfg or {}).get("mongodb") or {}).get("db") + if isinstance(value, str) and value.strip(): + return value.strip() + except Exception: + pass + return "Inventarsystem" + + +def _create_instance_admin_user( + subdomain: str, + username: str, + password: str, + first_name: str, + last_name: str, +) -> tuple[bool, str]: + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + return False, "Instanzverzeichnis nicht gefunden." + + user_name = _sanitize_text(username, 80) + pwd = (password or "").strip() + first = _sanitize_text(first_name or "Admin", 80) or "Admin" + last = _sanitize_text(last_name or "User", 80) or "User" + + if not _validate_username(user_name): + return False, "Ungültiger Benutzername für Instanz-Admin." + if len(pwd) < 8: + return False, "Passwort muss mindestens 8 Zeichen haben." + + pwd_hash = hashlib.sha512(pwd.encode("utf-8")).hexdigest() + db_name = _instance_db_name(instance_dir) + + js_user = json.dumps(user_name) + js_hash = json.dumps(pwd_hash) + js_first = json.dumps(first) + js_last = json.dumps(last) + + eval_script = ( + "const username=" + js_user + ";" + "const hash=" + js_hash + ";" + "const first=" + js_first + ";" + "const last=" + js_last + ";" + "const existing=db.users.findOne({Username:username});" + "if(existing){" + "db.users.updateOne({Username:username},{$set:{Password:hash,Admin:true,name:first,last_name:last,updated_at:new Date().toISOString()}});" + "print('UPDATED');" + "}else{" + "db.users.insertOne({Username:username,Password:hash,Admin:true,active_ausleihung:null,name:first,last_name:last,favorites:[]});" + "print('CREATED');" + "}" + ) + + up_ok, up_out = _run_command( + ["docker", "compose", "--env-file", ".docker-build.env", "up", "-d", "mongodb"], + cwd=instance_dir, + timeout=180, + ) + if not up_ok: + return False, f"MongoDB der Instanz konnte nicht gestartet werden: {_tail_output(up_out, 10)}" + + ok, output = _run_command( + [ + "docker", + "compose", + "--env-file", + ".docker-build.env", + "exec", + "-T", + "mongodb", + "mongosh", + "--quiet", + "--eval", + eval_script, + db_name, + ], + cwd=instance_dir, + timeout=240, + ) + + if not ok: + return False, f"Instanz-Admin konnte nicht angelegt werden: {_tail_output(output, 12)}" + + state = "aktualisiert" if "UPDATED" in output else "erstellt" + return True, f"Instanz-Admin '{user_name}' wurde {state}." + + +def _get_school_instance_by_subdomain(subdomain: str) -> dict | None: + key = _sanitize_text(subdomain or "", 63) + if not key: + return None + + client = None + try: + client, col = _get_collection("school_instances") + return col.find_one({"subdomain": key}) + except PyMongoError: + return None + finally: + if client: + client.close() + + +def _get_instance_for_user(username: str, display_name: str) -> dict | None: + uname = _sanitize_text(username or "", 80) + if not uname: + return None + + client = None + try: + client, col = _get_collection("school_instances") + return col.find_one({"owner_username": uname}, sort=[("updated_at", -1), ("created_at", -1)]) + except PyMongoError: + return None + finally: + if client: + client.close() + + def _activate_test_license_for_user(username: str, school_name: str, package_name: str = "Normal") -> tuple[bool, str]: """Create a one-time test license for the user if none exists yet.""" user_name = _sanitize_text(username or "", 80) @@ -697,6 +1283,319 @@ def admin_dashboard(): ) +@app.route('/admin/instances', methods=['GET', 'POST']) +@admin_required +def admin_instances(): + version_options = _parse_instance_version_options() + available_users = _list_available_instance_users() + + if request.method == 'POST': + action = _sanitize_text(request.form.get("action") or "create", 20).lower() + posted_subdomain = _sanitize_text(request.form.get("subdomain") or "", 120) + school_name = _sanitize_text(request.form.get("school_name") or "", 120) + raw_subdomain = _sanitize_text(request.form.get("subdomain") or "", 120) + owner_username = _sanitize_text(request.form.get("owner_username") or "", 80) + app_image_tag = _sanitize_text(request.form.get("app_image_tag") or "latest", 80) + library_enabled = (request.form.get("library_enabled") or "").strip().lower() in {"1", "on", "true", "yes"} + subdomain = _slugify_subdomain(posted_subdomain or raw_subdomain or school_name) + + if action == "delete": + if not _is_valid_subdomain(subdomain): + flash("Ungültige Subdomain für Löschung.", "error") + return redirect(url_for("admin_instances")) + + existing_record = _get_school_instance_by_subdomain(subdomain) + target_dir = _instance_dir_path(subdomain) + dir_exists = bool(target_dir and os.path.isdir(target_dir)) + if not existing_record and not dir_exists: + flash("Instanz nicht gefunden.", "error") + return redirect(url_for("admin_instances")) + + delete_ok, delete_message = _delete_instance_stack(subdomain) + if not delete_ok: + flash(delete_message or "Instanz konnte nicht gelöscht werden.", "error") + return redirect(url_for("admin_instances")) + + if existing_record and not _delete_school_instance(subdomain): + flash( + "Instanz wurde technisch gelöscht, aber der Datenbankeintrag konnte nicht entfernt werden.", + "error", + ) + return redirect(url_for("admin_instances")) + + flash(f"Instanz {subdomain} wurde gelöscht.", "success") + if delete_message: + flash(delete_message, "info") + return redirect(url_for("admin_instances")) + + if action == "toggle_library": + target = _get_school_instance_by_subdomain(subdomain) + if not target: + flash("Instanz nicht gefunden.", "error") + return redirect(url_for("admin_instances")) + + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + flash("Instanzverzeichnis nicht gefunden.", "error") + return redirect(url_for("admin_instances")) + + target_enabled = (request.form.get("library_enabled") or "").strip().lower() in {"1", "on", "true", "yes"} + ok, message = _set_instance_library_enabled(instance_dir, target_enabled) + if not ok: + _upsert_school_instance( + { + "subdomain": subdomain, + "status": "Fehler", + "nginx_status": "error", + "last_message": message, + } + ) + flash(message, "error") + return redirect(url_for("admin_instances")) + + restart_ok, restart_output = _restart_instance_stack(instance_dir) + client = None + try: + client, col = _get_collection("school_instances") + col.update_one( + {"subdomain": subdomain}, + { + "$set": { + "school_name": target.get("school_name") or "", + "owner_username": target.get("owner_username") or "", + "subdomain": subdomain, + "domain": target.get("domain") or f"{subdomain}.{INSTANCE_PARENT_DOMAIN}", + "https_port": int(target.get("https_port") or 0), + "instance_dir": instance_dir, + "app_image_tag": target.get("app_image_tag") or "latest", + "library_enabled": target_enabled, + "status": "Läuft" if restart_ok else "Fehler", + "nginx_status": "ok" if restart_ok else "error", + "last_message": "Bibliothek aktiviert/deaktiviert und Instanz neu gestartet." if restart_ok else _tail_output(restart_output, 18), + "updated_at": _utc_now_iso(), + }, + "$setOnInsert": {"created_at": _utc_now_iso()}, + }, + upsert=True, + ) + except PyMongoError: + pass + finally: + if client: + client.close() + + if restart_ok: + flash(f"Bibliothek wurde {'aktiviert' if target_enabled else 'deaktiviert'} und die Instanz neu gestartet.", "success") + if restart_output: + flash(_tail_output(restart_output, 12), "info") + else: + flash(f"Bibliothek wurde gespeichert, aber der Neustart ist fehlgeschlagen.\n{_tail_output(restart_output, 18)}", "error") + return redirect(url_for("admin_instances")) + + if action not in {"create", "start"}: + flash("Ungültige Aktion für Instanzverwaltung.", "error") + return redirect(url_for("admin_instances")) + + if not available_users: + flash("Keine freien Nutzer verfügbar. Bitte zuerst einen neuen Nutzer anlegen oder eine bestehende Instanz löschen.", "error") + return redirect(url_for("admin_instances")) + + if not school_name: + flash("Bitte einen Schulnamen angeben.", "error") + return redirect(url_for("admin_instances")) + + if not owner_username: + flash("Bitte einen Nutzer zuweisen.", "error") + return redirect(url_for("admin_instances")) + + owner_doc = _find_user(owner_username) + if not owner_doc: + flash("Ausgewählter Nutzer wurde nicht gefunden.", "error") + return redirect(url_for("admin_instances")) + + if not school_name: + school_name = _sanitize_text(owner_doc.get("display_name") or owner_username, 120) + + if app_image_tag not in version_options: + flash("Ungültige Version ausgewählt.", "error") + return redirect(url_for("admin_instances")) + + existing_for_owner = _get_instance_for_user(owner_username, "") + if existing_for_owner: + existing_sub = _sanitize_text(existing_for_owner.get("subdomain") or "", 63) + if existing_sub and existing_sub != subdomain: + flash( + f"Nutzer {owner_username} ist bereits der Instanz {existing_sub} zugewiesen. " + "Bitte erst diese Zuweisung ändern.", + "error", + ) + return redirect(url_for("admin_instances")) + + if not _is_valid_subdomain(subdomain): + flash("Ungültige Subdomain. Erlaubt sind a-z, 0-9 und Bindestriche (3-63 Zeichen).", "error") + return redirect(url_for("admin_instances")) + + success, message, details = _run_instance_provision( + action, + school_name, + subdomain, + app_image_tag=app_image_tag, + library_enabled=library_enabled, + ) + + instance_data = { + "school_name": school_name, + "owner_username": owner_username, + "subdomain": details.get("SUBDOMAIN") or subdomain, + "domain": details.get("DOMAIN") or f"{subdomain}.{INSTANCE_PARENT_DOMAIN}", + "https_port": int((details.get("HTTPS_PORT") or "0") or 0), + "instance_dir": details.get("INSTANCE_DIR") or os.path.join(INSTANCE_BASE_DIR, subdomain), + "app_image_tag": details.get("APP_IMAGE_TAG") or app_image_tag, + "library_enabled": library_enabled if details.get("LIBRARY_ENABLED") is None else details.get("LIBRARY_ENABLED") == "1", + "status": "Läuft" if success else "Fehler", + "nginx_status": details.get("NGINX_STATUS") or ("ok" if success else "error"), + "last_message": message, + } + _upsert_school_instance(instance_data) + + if success: + flash(f"Instanz gestartet: {instance_data['domain']}", "success") + if message: + flash(message, "info") + else: + flash(message or "Instanz konnte nicht gestartet werden.", "error") + + return redirect(url_for("admin_instances")) + + instances = _list_school_instances() + return render_template( + "admin_instances.html", + instances=instances, + users=_list_users_for_admin(), + available_users=available_users, + version_options=version_options, + instance_repo_url=INSTANCE_REPO_URL, + parent_domain=INSTANCE_PARENT_DOMAIN, + base_dir=INSTANCE_BASE_DIR, + provision_script=INSTANCE_PROVISION_SCRIPT, + ) + + +@app.route('/admin/system', methods=['GET', 'POST']) +@admin_required +def admin_system_tools(): + if request.method == 'POST': + action = _sanitize_text(request.form.get("action") or "", 40) + subdomain = _sanitize_text(request.form.get("subdomain") or "", 63) + + if action == "restart_core": + compose_file = os.path.join(BASE_DIR, "docker-compose.yml") + ok, output = _run_command( + ["docker", "compose", "-f", compose_file, "restart", "website", "mongodb"], + cwd=BASE_DIR, + timeout=180, + ) + flash("Core-Services wurden neugestartet." if ok else f"Core-Restart fehlgeschlagen:\n{_tail_output(output, 10)}", "success" if ok else "error") + return redirect(url_for("admin_system_tools")) + + if action in {"backup_instance", "update_instance", "restart_instance"}: + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + flash("Instanz nicht gefunden oder ungültige Subdomain.", "error") + return redirect(url_for("admin_system_tools")) + + if action == "backup_instance": + command = ["bash", "./backup.sh", "--mode", "auto"] + timeout = 1800 + title = f"Backup für {subdomain}" + elif action == "update_instance": + command = ["bash", "./update.sh"] + timeout = 2400 + title = f"Update für {subdomain}" + else: + title = f"Restart für {subdomain}" + ok, output = _restart_instance_stack(instance_dir) + if ok: + flash(f"{title} erfolgreich.\n{_tail_output(output, 12)}", "success") + _upsert_school_instance( + { + "subdomain": subdomain, + "status": "Läuft", + "nginx_status": "ok", + "last_message": "Instanz über System-Tools neu gestartet.", + } + ) + else: + flash(f"{title} fehlgeschlagen.\n{_tail_output(output, 18)}", "error") + _upsert_school_instance( + { + "subdomain": subdomain, + "status": "Fehler", + "nginx_status": "error", + "last_message": _sanitize_text(_tail_output(output, 6), 500), + } + ) + return redirect(url_for("admin_system_tools")) + + ok, output = _run_command(command, cwd=instance_dir, timeout=timeout) + if ok: + flash(f"{title} erfolgreich.\n{_tail_output(output, 12)}", "success") + else: + flash(f"{title} fehlgeschlagen.\n{_tail_output(output, 18)}", "error") + return redirect(url_for("admin_system_tools")) + + if action == "create_instance_admin": + username = _sanitize_text(request.form.get("admin_username") or "", 80) + password = (request.form.get("admin_password") or "").strip() + first_name = _sanitize_text(request.form.get("admin_first_name") or "Admin", 80) + last_name = _sanitize_text(request.form.get("admin_last_name") or "User", 80) + + ok, msg = _create_instance_admin_user(subdomain, username, password, first_name, last_name) + flash(msg, "success" if ok else "error") + return redirect(url_for("admin_system_tools")) + + flash("Unbekannte System-Aktion.", "error") + return redirect(url_for("admin_system_tools")) + + instances = _list_school_instances() + return render_template("admin_system.html", instances=instances) + + +@app.route('/admin/system/logs/core') +@admin_required +def admin_download_core_logs(): + ok, output = _collect_core_logs() + if not ok: + flash(f"Core-Logs konnten nicht geladen werden.\n{_tail_output(output, 14)}", "error") + return redirect(url_for("admin_system_tools")) + + filename = f"core-logs-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.log" + return send_file( + BytesIO(output.encode("utf-8")), + mimetype="text/plain", + as_attachment=True, + download_name=filename, + ) + + +@app.route('/admin/system/logs/instance/') +@admin_required +def admin_download_instance_logs(subdomain): + ok, output = _collect_instance_logs(subdomain) + if not ok: + flash(f"Instanz-Logs konnten nicht geladen werden.\n{_tail_output(output, 14)}", "error") + return redirect(url_for("admin_system_tools")) + + safe_name = _slugify_subdomain(subdomain) + filename = f"instance-{safe_name}-logs-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.log" + return send_file( + BytesIO(output.encode("utf-8")), + mimetype="text/plain", + as_attachment=True, + download_name=filename, + ) + + @app.route('/admin/appointments/block-day', methods=['POST']) @admin_required def admin_block_day(): @@ -951,6 +1850,44 @@ def my_invoices(): return render_template("my_invoices.html", invoices=invoices) +@app.route('/my/instance', methods=['GET', 'POST']) +@login_required +def my_instance_management(): + username = _sanitize_text(session.get("username") or "", 80) + display_name = _sanitize_text(session.get("display_name") or username, 120) + + current_instance = _get_instance_for_user(username, display_name) + current_subdomain = _sanitize_text((current_instance or {}).get("subdomain") or "", 63) + suggested_subdomain = _slugify_subdomain(display_name or username) + + if request.method == 'POST': + flash("Nutzer können Instanzen nicht selbst erstellen oder ändern. Bitte den Administrator kontaktieren.", "error") + return redirect(url_for("my_instance_management")) + + instance_doc = _get_instance_for_user(username, display_name) + instance_view = None + if instance_doc: + instance_view = { + "school_name": _sanitize_text(instance_doc.get("school_name") or display_name, 120), + "owner_username": _sanitize_text(instance_doc.get("owner_username") or "", 80), + "subdomain": _sanitize_text(instance_doc.get("subdomain") or "", 63), + "domain": _sanitize_text(instance_doc.get("domain") or "", 190), + "https_port": int(instance_doc.get("https_port") or 0), + "status": _sanitize_text(instance_doc.get("status") or "Unbekannt", 40), + "nginx_status": _sanitize_text(instance_doc.get("nginx_status") or "unbekannt", 80), + "last_message": _sanitize_text(instance_doc.get("last_message") or "", 500), + "updated_at": instance_doc.get("updated_at") or "", + } + + return render_template( + "my_instance.html", + instance=instance_view, + suggested_subdomain=suggested_subdomain, + parent_domain=INSTANCE_PARENT_DOMAIN, + default_school_name=display_name, + ) + + @app.route('/chat', methods=['GET', 'POST']) @login_required def user_chat(): @@ -1068,7 +2005,8 @@ def admin_users(): return redirect(url_for("admin_users")) users = _list_users_for_admin() - return render_template("admin_users.html", users=users) + instances_by_owner = _list_instances_grouped_by_owner() + return render_template("admin_users.html", users=users, instances_by_owner=instances_by_owner) @app.route('/admin/team', methods=['GET', 'POST']) diff --git a/Website/provision_instance.sh b/Website/provision_instance.sh new file mode 100755 index 0000000..4a84457 --- /dev/null +++ b/Website/provision_instance.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="create" +SCHOOL_NAME="" +SUBDOMAIN="" +REPO_URL="" +BASE_DIR="" +PARENT_DOMAIN="" +HTTPS_PORT="" +HTTP_PORT="" +TLS_MODE="development" +APP_IMAGE_TAG="latest" +LIBRARY_ENABLED="0" +WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt" +WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key" +NGINX_SITES_AVAILABLE="/etc/nginx/sites-available" +NGINX_SITES_ENABLED="/etc/nginx/sites-enabled" + +print_kv() { + local key="$1" + local value="$2" + printf '%s=%s\n' "$key" "$value" +} + +fail() { + local message="$1" + print_kv "MESSAGE" "$message" + print_kv "ERROR" "$message" + exit 1 +} + +slugify() { + local input="$1" + input="${input,,}" + input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')" + input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')" + printf '%s' "${input:0:63}" +} + +is_valid_subdomain() { + local value="$1" + [[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]] +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd" +} + +is_port_in_use() { + local port="$1" + + # Running inside container namespaces cannot reliably see host listeners, + # but docker CLI can inspect host-published ports via the socket. + if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then + return 0 + fi + + if command -v ss >/dev/null 2>&1; then + ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0 + fi + + return 1 +} + +next_free_port() { + local start_port="$1" + local current="$start_port" + while is_port_in_use "$current"; do + current=$((current + 1)) + done + printf '%s' "$current" +} + +write_env_file() { + local target_dir="$1" + local http_port="$2" + local https_port="$3" + local app_image_tag="$4" + + cat > "$target_dir/.docker-build.env" <= len(ports_indent): + i += 1 + continue + break + continue + + out.append(line) + i += 1 + +normalized = "\n".join(out) +if content.endswith("\n"): + normalized += "\n" + +if normalized != content: + path.write_text(normalized, encoding="utf-8") +PY +} + +run_instance_start() { + local start_output + local retry_output + local update_output + + stack_is_running() { + local running_services + running_services="$(docker compose --env-file .docker-build.env ps --status running --services 2>/dev/null || true)" + printf '%s\n' "$running_services" | grep -Fxq app || return 1 + printf '%s\n' "$running_services" | grep -Fxq nginx || return 1 + printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1 + return 0 + } + + if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then + return 0 + fi + + if stack_is_running; then + print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)." + return 0 + fi + + if printf '%s' "$start_output" | grep -qi "local app image not found"; then + if [ ! -x ./update.sh ]; then + fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)" + fi + + if ! update_output="$(bash ./update.sh 2>&1)"; then + if stack_is_running; then + print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)." + return 0 + fi + fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)" + fi + + if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then + print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)." + return 0 + fi + + fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)" + fi + + fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)" +} + +setup_or_update_repo() { + local target_dir="$1" + local repo_url="$2" + + if [ -d "$target_dir/.git" ]; then + git -C "$target_dir" fetch --all --prune + git -C "$target_dir" pull --ff-only origin main || true + return 0 + fi + + if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then + fail "Zielverzeichnis ist nicht leer: $target_dir" + fi + + mkdir -p "$target_dir" + git clone "$repo_url" "$target_dir" +} + +write_nginx_site() { + local subdomain="$1" + local full_domain="$2" + local https_port="$3" + local cert_file="" + local key_file="" + local cert_dir="/etc/nginx/certs" + local site_name="inventarsystem-${subdomain}.conf" + local avail_file="$NGINX_SITES_AVAILABLE/$site_name" + local enabled_file="$NGINX_SITES_ENABLED/$site_name" + + if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then + print_kv "NGINX_STATUS" "skipped" + print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen." + return 0 + fi + + if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen." + return 0 + fi + + if [ "$TLS_MODE" = "production" ]; then + cert_file="$WILDCARD_CERT_FILE" + key_file="$WILDCARD_KEY_FILE" + + if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then + print_kv "NGINX_STATUS" "error" + print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file" + return 0 + fi + else + cert_file="$cert_dir/$full_domain.crt" + key_file="$cert_dir/$full_domain.key" + + if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then + if ! command -v openssl >/dev/null 2>&1; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen." + return 0 + fi + + if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat." + return 0 + fi + + if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \ + -days 825 \ + -keyout "$key_file" \ + -out "$cert_file" \ + -subj "/CN=$full_domain" \ + -addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then + openssl req -x509 -nodes -newkey rsa:2048 -sha256 \ + -days 825 \ + -keyout "$key_file" \ + -out "$cert_file" \ + -subj "/CN=$full_domain" >/dev/null 2>&1 || { + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden." + return 0 + } + fi + + chmod 600 "$key_file" 2>/dev/null || true + chmod 644 "$cert_file" 2>/dev/null || true + fi + fi + + cat > "$avail_file" </dev/null 2>&1; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen." + return 0 + fi + + if nginx -t >/dev/null 2>&1; then + nginx -s reload >/dev/null 2>&1 || true + print_kv "NGINX_STATUS" "ok" + else + print_kv "NGINX_STATUS" "error" + print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen." + return 0 + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --school-name) + SCHOOL_NAME="${2:-}" + shift 2 + ;; + --subdomain) + SUBDOMAIN="${2:-}" + shift 2 + ;; + --repo) + REPO_URL="${2:-}" + shift 2 + ;; + --base-dir) + BASE_DIR="${2:-}" + shift 2 + ;; + --domain) + PARENT_DOMAIN="${2:-}" + shift 2 + ;; + --https-port) + HTTPS_PORT="${2:-}" + shift 2 + ;; + --http-port) + HTTP_PORT="${2:-}" + shift 2 + ;; + --tls-mode) + TLS_MODE="${2:-}" + shift 2 + ;; + --wildcard-cert-file) + WILDCARD_CERT_FILE="${2:-}" + shift 2 + ;; + --wildcard-key-file) + WILDCARD_KEY_FILE="${2:-}" + shift 2 + ;; + --app-image-tag) + APP_IMAGE_TAG="${2:-latest}" + shift 2 + ;; + --library-enabled) + LIBRARY_ENABLED="${2:-0}" + shift 2 + ;; + *) + fail "Unbekannter Parameter: $1" + ;; + esac +done + +[ -n "$REPO_URL" ] || fail "Repository-URL fehlt" +[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt" +[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt" + +if [ -z "$SUBDOMAIN" ]; then + SUBDOMAIN="$(slugify "$SCHOOL_NAME")" +else + SUBDOMAIN="$(slugify "$SUBDOMAIN")" +fi + +is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN" + +INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN" +FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN" + +require_cmd git +require_cmd docker +require_cmd bash + +mkdir -p "$BASE_DIR" + +if [ -z "$HTTPS_PORT" ]; then + HTTPS_PORT="$(next_free_port 15443)" +fi + +if [ -z "$HTTP_PORT" ]; then + HTTP_PORT="$(next_free_port 15080)" +fi + +if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then + setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL" + + [ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR" + + write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG" + set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED" + + cd "$INSTANCE_DIR" + normalize_instance_compose + run_instance_start + + write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT" + + print_kv "SUBDOMAIN" "$SUBDOMAIN" + print_kv "DOMAIN" "$FULL_DOMAIN" + print_kv "HTTPS_PORT" "$HTTPS_PORT" + print_kv "HTTP_PORT" "$HTTP_PORT" + print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG" + print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED" + print_kv "INSTANCE_DIR" "$INSTANCE_DIR" + exit 0 +fi + +fail "Nicht unterstuetzte Action: $ACTION" diff --git a/Website/start_docker_build.sh b/Website/start_docker_build.sh index 54be0fc..f329c72 100755 --- a/Website/start_docker_build.sh +++ b/Website/start_docker_build.sh @@ -33,15 +33,8 @@ if ! "${DOCKER_CMD[@]}" compose version >/dev/null 2>&1; then exit 1 fi -NUITKA_JOBS="${NUITKA_JOBS:-1}" -NUITKA_LOW_MEMORY="${NUITKA_LOW_MEMORY:-1}" - -echo "==> Safe Nuitka build settings" -echo " NUITKA_JOBS=${NUITKA_JOBS}" -echo " NUITKA_LOW_MEMORY=${NUITKA_LOW_MEMORY}" - -echo "==> Building website image (Nuitka standalone conversion runs in Dockerfile)" -NUITKA_JOBS="$NUITKA_JOBS" NUITKA_LOW_MEMORY="$NUITKA_LOW_MEMORY" "${DOCKER_CMD[@]}" compose build website +echo "==> Building website image (pure Python runtime)" +"${DOCKER_CMD[@]}" compose build website echo "==> Starting internal MongoDB + Website containers" "${DOCKER_CMD[@]}" compose up -d @@ -51,4 +44,4 @@ echo "==> Container status" echo "Application is starting on: http://localhost:4999" echo "Use: docker compose logs -f website" -echo "Tip: If build is still too heavy, run with NUITKA_JOBS=1 NUITKA_LOW_MEMORY=1 ./start_docker_build.sh" +echo "Provisioning is available in the container via /app/provision_instance.sh" diff --git a/Website/sync_dev_hosts.sh b/Website/sync_dev_hosts.sh new file mode 100755 index 0000000..9e9fbc2 --- /dev/null +++ b/Website/sync_dev_hosts.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +HOSTS_FILE="/etc/hosts" +START_MARK="# >>> invario-dev-hosts >>>" +END_MARK="# <<< invario-dev-hosts <<<" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker fehlt; Hosts-Sync übersprungen" + exit 0 +fi + +if ! docker compose ps mongodb >/dev/null 2>&1; then + echo "mongodb-compose-Service nicht erreichbar; Hosts-Sync übersprungen" + exit 0 +fi + +DOMAINS_RAW="$(docker compose exec -T mongodb mongosh --quiet --eval 'db.getSiblingDB("Invario_Website").school_instances.find({},{_id:0,domain:1}).toArray().forEach(d=>{ if(d.domain){ print(String(d.domain).trim()) } })' 2>/dev/null || true)" + +TMP_BLOCK="$(mktemp)" +trap 'rm -f "$TMP_BLOCK"' EXIT + +{ + echo "$START_MARK" + echo "127.0.0.1 localhost" + while IFS= read -r line; do + domain="$(echo "$line" | tr -d '\r' | xargs)" + if [[ -n "$domain" ]]; then + echo "127.0.0.1 $domain" + fi + done <<< "$DOMAINS_RAW" + echo "$END_MARK" +} > "$TMP_BLOCK" + +if [[ "${EUID}" -eq 0 ]]; then + sed -i "/$START_MARK/,/$END_MARK/d" "$HOSTS_FILE" + cat "$TMP_BLOCK" >> "$HOSTS_FILE" +else + sudo sed -i "/$START_MARK/,/$END_MARK/d" "$HOSTS_FILE" + sudo tee -a "$HOSTS_FILE" < "$TMP_BLOCK" >/dev/null +fi + +echo "Hosts-Sync abgeschlossen" \ No newline at end of file diff --git a/Website/templates/admin_dashboard.html b/Website/templates/admin_dashboard.html index 08e19da..b03b701 100644 --- a/Website/templates/admin_dashboard.html +++ b/Website/templates/admin_dashboard.html @@ -274,6 +274,10 @@

Team verwalten: Bilder, Rolle und Arbeit der zwei Team-Cards zentral pflegen.

Team Cards öffnen +

Buchungsanfragen verwalten

Kalender-Tage sperren

diff --git a/Website/templates/admin_instances.html b/Website/templates/admin_instances.html new file mode 100644 index 0000000..9104d9e --- /dev/null +++ b/Website/templates/admin_instances.html @@ -0,0 +1,308 @@ +{% extends "base.html" %} + +{% block title %}Admin | Instanzen{% endblock %} + +{% block content %} +
+

Schul-Instanzen starten

+

Neue Instanzen des Inventarsystems für Subdomains pro Schule anlegen, sowie die Bibliothek dauerhaft aktivieren oder deaktivieren.

+
+ +
+
+ + + + + + {% if not available_users %} +

Alle Nutzer sind bereits einer Instanz zugewiesen. Lege einen neuen Nutzer an oder lösche eine bestehende Instanz.

+ {% endif %} + + + + + + + + + + + + + +
+ + +
+ +
+

Vorhandene Instanzen

+ {% if instances %} + + + + + + + + + + + + + + + + + + {% for item in instances %} + + + + + + + + + + + + + + {% endfor %} + +
SchuleNutzerSubdomainVersionBibliothekAktionHTTPS-PortStatusNginxLetzte MeldungAktualisiert
{{ item.school_name }}{{ item.owner_username or '-' }} + {% if item.domain %} + {{ item.domain }} + {% else %} + - + {% endif %} + {{ item.app_image_tag or 'latest' }}{{ 'an' if item.library_enabled else 'aus' }} +
+ + + + +
+
+ + + +
+
{{ item.https_port or '-' }} + {{ item.status }} + {{ item.nginx_status }}{{ item.last_message }}{{ item.updated_at }}
+ {% else %} +

Noch keine Instanzen vorhanden.

+ {% endif %} +
+ + + + +{% endblock %} diff --git a/Website/templates/admin_system.html b/Website/templates/admin_system.html new file mode 100644 index 0000000..d8a917f --- /dev/null +++ b/Website/templates/admin_system.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} + +{% block title %}Admin | System-Tools{% endblock %} + +{% block content %} +
+

System-Tools

+

Nützliche Admin-Funktionen für Betrieb, Backup, Update, Restart und Log-Download.

+
+ +
+

Core-Services

+
+
+ + +
+ Core-Logs herunterladen +
+
+ +
+

Instanz-Operationen

+ {% if instances %} + + + + + + + + + + + {% for item in instances %} + + + + + + + {% endfor %} + +
SchuleSubdomainStatusAktionen
{{ item.school_name }}{{ item.subdomain }}{{ item.status }} / {{ item.nginx_status }} +
+
+ + + +
+
+ + + +
+
+ + + +
+ Logs +
+
+ + + + + + + +
+
+ {% else %} +

Keine Instanzen vorhanden.

+ {% endif %} +
+ + +{% endblock %} diff --git a/Website/templates/admin_users.html b/Website/templates/admin_users.html index 4c95532..8e77c1a 100644 --- a/Website/templates/admin_users.html +++ b/Website/templates/admin_users.html @@ -15,6 +15,7 @@ Benutzername Name Rolle + Instanz-Links Aktionen @@ -24,6 +25,18 @@ {{ user.username }} {{ user.display_name }} {{ 'Admin' if user.is_admin else 'Nutzer' }} + + {% set user_instances = instances_by_owner.get(user.username, []) %} + {% if user_instances %} + + {% else %} + Keine zugewiesene Instanz + {% endif %} +
@@ -51,6 +64,10 @@ table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 0.7rem; border-bottom: 1px solid #e8eef2; } .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } +.instance-links { display: grid; gap: 0.35rem; } +.instance-links a { color: #0b5b89; font-weight: 600; text-decoration: none; } +.instance-links a:hover { text-decoration: underline; } +.no-instance { color: #5e7283; } select { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.55rem; border-radius: 10px; font: inherit; } button { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.65rem; border-radius: 999px; font-weight: 700; cursor: pointer; } diff --git a/Website/templates/base.html b/Website/templates/base.html index 6317bed..7887195 100644 --- a/Website/templates/base.html +++ b/Website/templates/base.html @@ -385,6 +385,7 @@ @@ -401,8 +402,10 @@ Rechnungsverwaltung Admin-Chat Support-Center + System-Tools Blog verwalten Team Cards + Schul-Instanzen
{% endif %} diff --git a/Website/templates/my_instance.html b/Website/templates/my_instance.html new file mode 100644 index 0000000..f23b9d2 --- /dev/null +++ b/Website/templates/my_instance.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Meine Instanz{% endblock %} + +{% block content %} +
+

Meine Instanzverwaltung

+

Hier sehen Sie ausschließlich Ihre zugewiesene Instanz und können diese direkt nutzen.

+
+ +
+

Instanzdaten

+ {% if instance %} +

Schule: {{ instance.school_name }}

+

Domain: {{ instance.domain }}

+

Subdomain: {{ instance.subdomain }}

+

HTTPS-Port: {{ instance.https_port or '-' }}

+

Status: {{ instance.status }}

+

Nginx: {{ instance.nginx_status }}

+

Letzte Meldung: {{ instance.last_message }}

+

Aktualisiert: {{ instance.updated_at }}

+

Zugang: https://{{ instance.domain }}

+ {% else %} +

Für Ihr Konto ist aktuell keine Instanz zugewiesen.

+

Bitte wenden Sie sich an den Administrator, um eine Instanz zugewiesen zu bekommen.

+ {% endif %} +
+ + +{% endblock %} diff --git a/provision_instance.sh b/provision_instance.sh new file mode 100755 index 0000000..ed98d6d --- /dev/null +++ b/provision_instance.sh @@ -0,0 +1,475 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="create" +SCHOOL_NAME="" +SUBDOMAIN="" +REPO_URL="" +BASE_DIR="" +PARENT_DOMAIN="" +HTTPS_PORT="" +HTTP_PORT="" +TLS_MODE="development" +APP_IMAGE_TAG="latest" +LIBRARY_ENABLED="0" +WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt" +WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key" +NGINX_SITES_AVAILABLE="/etc/nginx/sites-available" +NGINX_SITES_ENABLED="/etc/nginx/sites-enabled" + +print_kv() { + local key="$1" + local value="$2" + printf '%s=%s\n' "$key" "$value" +} + +fail() { + local message="$1" + print_kv "MESSAGE" "$message" + print_kv "ERROR" "$message" + exit 1 +} + +slugify() { + local input="$1" + input="${input,,}" + input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')" + input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')" + printf '%s' "${input:0:63}" +} + +is_valid_subdomain() { + local value="$1" + [[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]] +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd" +} + +is_port_in_use() { + local port="$1" + + if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then + return 0 + fi + + if command -v ss >/dev/null 2>&1; then + ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0 + fi + + return 1 +} + +next_free_port() { + local start_port="$1" + local current="$start_port" + while is_port_in_use "$current"; do + current=$((current + 1)) + done + printf '%s' "$current" +} + +write_env_file() { + local target_dir="$1" + local http_port="$2" + local https_port="$3" + local app_image_tag="$4" + + cat > "$target_dir/.docker-build.env" <= 6: + i += 1 + continue + break + continue + + out.append(line) + i += 1 + +normalized = "\n".join(out) +if content.endswith("\n"): + normalized += "\n" + +if normalized != content: + path.write_text(normalized, encoding="utf-8") +PY +} + +run_instance_start() { + local start_output + local retry_output + local update_output + + stack_is_running() { + local running_services + running_services="$(docker compose --env-file .docker-build.env ps --status running --services 2>/dev/null || true)" + printf '%s\n' "$running_services" | grep -Fxq app || return 1 + printf '%s\n' "$running_services" | grep -Fxq nginx || return 1 + printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1 + return 0 + } + + if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then + return 0 + fi + + if stack_is_running; then + print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)." + return 0 + fi + + if printf '%s' "$start_output" | grep -qi "local app image not found"; then + if [ ! -x ./update.sh ]; then + fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)" + fi + + if ! update_output="$(bash ./update.sh 2>&1)"; then + if stack_is_running; then + print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)." + return 0 + fi + fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)" + fi + + if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then + print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)." + return 0 + fi + + fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)" + fi + + fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)" +} + +setup_or_update_repo() { + local target_dir="$1" + local repo_url="$2" + + if [ -d "$target_dir/.git" ]; then + git -C "$target_dir" fetch --all --prune + git -C "$target_dir" pull --ff-only origin main || true + return 0 + fi + + if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then + fail "Zielverzeichnis ist nicht leer: $target_dir" + fi + + mkdir -p "$target_dir" + git clone "$repo_url" "$target_dir" +} + +write_nginx_site() { + local subdomain="$1" + local full_domain="$2" + local https_port="$3" + local cert_file="" + local key_file="" + local cert_dir="/etc/nginx/certs" + local site_name="inventarsystem-${subdomain}.conf" + local avail_file="$NGINX_SITES_AVAILABLE/$site_name" + local enabled_file="$NGINX_SITES_ENABLED/$site_name" + + if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then + print_kv "NGINX_STATUS" "skipped" + print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen." + return 0 + fi + + if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen." + return 0 + fi + + if [ "$TLS_MODE" = "production" ]; then + cert_file="$WILDCARD_CERT_FILE" + key_file="$WILDCARD_KEY_FILE" + + if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then + print_kv "NGINX_STATUS" "error" + print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file" + return 0 + fi + else + cert_file="$cert_dir/$full_domain.crt" + key_file="$cert_dir/$full_domain.key" + + if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then + if ! command -v openssl >/dev/null 2>&1; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen." + return 0 + fi + + if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat." + return 0 + fi + + if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \ + -days 825 \ + -keyout "$key_file" \ + -out "$cert_file" \ + -subj "/CN=$full_domain" \ + -addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then + openssl req -x509 -nodes -newkey rsa:2048 -sha256 \ + -days 825 \ + -keyout "$key_file" \ + -out "$cert_file" \ + -subj "/CN=$full_domain" >/dev/null 2>&1 || { + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden." + return 0 + } + fi + + chmod 600 "$key_file" 2>/dev/null || true + chmod 644 "$cert_file" 2>/dev/null || true + fi + fi + + cat > "$avail_file" </dev/null 2>&1; then + print_kv "NGINX_STATUS" "manual_required" + print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen." + return 0 + fi + + if nginx -t >/dev/null 2>&1; then + nginx -s reload >/dev/null 2>&1 || true + print_kv "NGINX_STATUS" "ok" + else + print_kv "NGINX_STATUS" "error" + print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen." + return 0 + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --action) + ACTION="${2:-}" + shift 2 + ;; + --school-name) + SCHOOL_NAME="${2:-}" + shift 2 + ;; + --subdomain) + SUBDOMAIN="${2:-}" + shift 2 + ;; + --repo) + REPO_URL="${2:-}" + shift 2 + ;; + --base-dir) + BASE_DIR="${2:-}" + shift 2 + ;; + --domain) + PARENT_DOMAIN="${2:-}" + shift 2 + ;; + --https-port) + HTTPS_PORT="${2:-}" + shift 2 + ;; + --http-port) + HTTP_PORT="${2:-}" + shift 2 + ;; + --tls-mode) + TLS_MODE="${2:-}" + shift 2 + ;; + --wildcard-cert-file) + WILDCARD_CERT_FILE="${2:-}" + shift 2 + ;; + --wildcard-key-file) + WILDCARD_KEY_FILE="${2:-}" + shift 2 + ;; + --app-image-tag) + APP_IMAGE_TAG="${2:-latest}" + shift 2 + ;; + --library-enabled) + LIBRARY_ENABLED="${2:-0}" + shift 2 + ;; + *) + fail "Unbekannter Parameter: $1" + ;; + esac +done + +[ -n "$REPO_URL" ] || fail "Repository-URL fehlt" +[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt" +[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt" + +if [ -z "$SUBDOMAIN" ]; then + SUBDOMAIN="$(slugify "$SCHOOL_NAME")" +else + SUBDOMAIN="$(slugify "$SUBDOMAIN")" +fi + +is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN" + +INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN" +FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN" + +require_cmd git +require_cmd docker +require_cmd bash + +mkdir -p "$BASE_DIR" + +if [ -z "$HTTPS_PORT" ]; then + HTTPS_PORT="$(next_free_port 15443)" +fi + +if [ -z "$HTTP_PORT" ]; then + HTTP_PORT="$(next_free_port 15080)" +fi + +if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then + setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL" + + [ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR" + + write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG" + set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED" + + cd "$INSTANCE_DIR" + normalize_instance_compose + run_instance_start + + write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT" + + print_kv "SUBDOMAIN" "$SUBDOMAIN" + print_kv "DOMAIN" "$FULL_DOMAIN" + print_kv "HTTPS_PORT" "$HTTPS_PORT" + print_kv "HTTP_PORT" "$HTTP_PORT" + print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG" + print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED" + print_kv "INSTANCE_DIR" "$INSTANCE_DIR" + exit 0 +fi + +fail "Nicht unterstuetzte Action: $ACTION"