Add admin instance management and system tools
- Implemented sync_dev_hosts.sh for managing local hosts entries for development. - Created admin_instances.html for managing school instances, including creation and listing. - Developed admin_system.html for core services management, including restart and backup operations. - Added my_instance.html for users to view and manage their assigned instances. - Introduced provision_instance.sh for provisioning new instances with Docker, including Nginx configuration and SSL certificate handling.
This commit is contained in:
@@ -92,3 +92,86 @@ Die Nginx-Vorlagen liegen unter [nginx](nginx):
|
||||
- [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)
|
||||
|
||||
## 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
|
||||
```
|
||||
+28
-34
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
|
||||
Executable
+24
@@ -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."
|
||||
Executable
+31
@@ -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."
|
||||
+939
-1
@@ -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/<subdomain>')
|
||||
@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'])
|
||||
|
||||
Executable
+483
@@ -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" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$http_port
|
||||
INVENTAR_HTTPS_PORT=$https_port
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
|
||||
EOF
|
||||
}
|
||||
|
||||
set_library_enabled() {
|
||||
local instance_dir="$1"
|
||||
local enabled="$2"
|
||||
local cfg="$instance_dir/config.json"
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
python3 - <<'PY' "$cfg" "$enabled"
|
||||
import json
|
||||
import sys
|
||||
|
||||
cfg_path = sys.argv[1]
|
||||
enabled = sys.argv[2] == "1"
|
||||
|
||||
with open(cfg_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
modules = data.get("modules")
|
||||
if not isinstance(modules, dict):
|
||||
modules = {}
|
||||
data["modules"] = modules
|
||||
|
||||
library = modules.get("library")
|
||||
if not isinstance(library, dict):
|
||||
library = {}
|
||||
modules["library"] = library
|
||||
|
||||
library["enabled"] = enabled
|
||||
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_instance_compose() {
|
||||
local compose_file="docker-compose.yml"
|
||||
[ -f "$compose_file" ] || return 0
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("docker-compose.yml")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
out = []
|
||||
in_nginx = False
|
||||
in_ports = False
|
||||
ports_indent = ""
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
|
||||
if stripped.startswith("nginx:") and indent == 2:
|
||||
in_nginx = True
|
||||
in_ports = False
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
|
||||
in_nginx = False
|
||||
in_ports = False
|
||||
|
||||
if stripped.startswith("container_name:") and indent == 4:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_nginx and stripped.startswith("ports:") and indent == 4:
|
||||
out.append(line)
|
||||
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
|
||||
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
|
||||
i += 1
|
||||
in_ports = True
|
||||
ports_indent = " "
|
||||
while i < len(lines):
|
||||
nxt = lines[i]
|
||||
nxt_stripped = nxt.strip()
|
||||
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
|
||||
if nxt_stripped.startswith("-") and nxt_indent >= 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" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $full_domain;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $full_domain;
|
||||
|
||||
ssl_certificate $cert_file;
|
||||
ssl_certificate_key $key_file;
|
||||
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:$https_port;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_ssl_verify off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sfn "$avail_file" "$enabled_file"
|
||||
|
||||
if ! command -v nginx >/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"
|
||||
@@ -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"
|
||||
|
||||
Executable
+46
@@ -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"
|
||||
@@ -274,6 +274,10 @@
|
||||
<p><strong>Team verwalten:</strong> Bilder, Rolle und Arbeit der zwei Team-Cards zentral pflegen.</p>
|
||||
<a href="{{ url_for('admin_team') }}">Team Cards öffnen</a>
|
||||
</div>
|
||||
<div class="quick-admin-link">
|
||||
<p><strong>Schul-Instanzen:</strong> Neue Subdomain-Instanzen für Schulen aus dem Inventarsystem-Repository starten.</p>
|
||||
<a href="{{ url_for('admin_instances') }}">Instanzen öffnen</a>
|
||||
</div>
|
||||
<h2>Buchungsanfragen verwalten</h2>
|
||||
<div class="block-days-section">
|
||||
<h3>Kalender-Tage sperren</h3>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin | Instanzen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="instance-header">
|
||||
<h1>Schul-Instanzen starten</h1>
|
||||
<p>Neue Instanzen des Inventarsystems für Subdomains pro Schule anlegen, sowie die Bibliothek dauerhaft aktivieren oder deaktivieren.</p>
|
||||
</section>
|
||||
|
||||
<section class="instance-form-wrap">
|
||||
<form method="post" class="instance-form">
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<label for="owner_username">Zugewiesener Nutzer</label>
|
||||
<select id="owner_username" name="owner_username" required {% if not available_users %}disabled{% endif %}>
|
||||
{% if available_users %}
|
||||
<option value="">Nutzer wählen</option>
|
||||
{% for user in available_users %}
|
||||
<option value="{{ user.username }}" data-school-name="{{ user.display_name or user.username }}">{{ user.username }}{% if user.display_name %} - {{ user.display_name }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">Keine freien Nutzer verfügbar</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
|
||||
{% if not available_users %}
|
||||
<p class="warn-text">Alle Nutzer sind bereits einer Instanz zugewiesen. Lege einen neuen Nutzer an oder lösche eine bestehende Instanz.</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="school_name">Schulname</label>
|
||||
<input id="school_name" name="school_name" type="text" placeholder="wird aus dem Nutzer übernommen" required>
|
||||
|
||||
<label for="app_image_tag">Version</label>
|
||||
<select id="app_image_tag" name="app_image_tag" required>
|
||||
{% for version in version_options %}
|
||||
<option value="{{ version }}">{{ version }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="subdomain">Subdomain (optional)</label>
|
||||
<input id="subdomain" name="subdomain" type="text" placeholder="wird aus Schulname abgeleitet">
|
||||
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="library_enabled" value="1">
|
||||
Bibliothek aktivieren
|
||||
</label>
|
||||
|
||||
<button type="submit">Instanz erstellen und starten</button>
|
||||
</form>
|
||||
|
||||
<aside class="instance-meta">
|
||||
<h2>Systemdaten</h2>
|
||||
<p><strong>Repository:</strong> {{ instance_repo_url }}</p>
|
||||
<p><strong>Basis-Domain:</strong> {{ parent_domain }}</p>
|
||||
<p><strong>Instanz-Pfad:</strong> {{ base_dir }}</p>
|
||||
<p><strong>Provisioning-Skript:</strong> {{ provision_script }}</p>
|
||||
<p class="note">Hinweis: Für Docker- und Nginx-Operationen benötigt der laufende Prozess die passenden Host-Berechtigungen.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="instance-table-wrap">
|
||||
<h2>Vorhandene Instanzen</h2>
|
||||
{% if instances %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schule</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Subdomain</th>
|
||||
<th>Version</th>
|
||||
<th>Bibliothek</th>
|
||||
<th>Aktion</th>
|
||||
<th>HTTPS-Port</th>
|
||||
<th>Status</th>
|
||||
<th>Nginx</th>
|
||||
<th>Letzte Meldung</th>
|
||||
<th>Aktualisiert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in instances %}
|
||||
<tr>
|
||||
<td>{{ item.school_name }}</td>
|
||||
<td>{{ item.owner_username or '-' }}</td>
|
||||
<td>
|
||||
{% if item.domain %}
|
||||
<a class="instance-domain-link" href="https://{{ item.domain }}" target="_blank" rel="noopener noreferrer">{{ item.domain }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.app_image_tag or 'latest' }}</td>
|
||||
<td>{{ 'an' if item.library_enabled else 'aus' }}</td>
|
||||
<td>
|
||||
<form method="post" class="inline-toggle-form">
|
||||
<input type="hidden" name="action" value="toggle_library">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<input type="hidden" name="library_enabled" value="{{ 0 if item.library_enabled else 1 }}">
|
||||
<button type="submit">{{ 'Bibliothek deaktivieren' if item.library_enabled else 'Bibliothek aktivieren' }}</button>
|
||||
</form>
|
||||
<form method="post" class="inline-toggle-form" onsubmit="return confirm('Instanz {{ item.subdomain }} wirklich löschen? Dieser Vorgang entfernt Container, Daten und Konfiguration.');">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<button type="submit" class="danger">Instanz löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ item.https_port or '-' }}</td>
|
||||
<td>
|
||||
<span class="pill {{ 'ok' if item.status == 'Läuft' else 'error' }}">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.nginx_status }}</td>
|
||||
<td>{{ item.last_message }}</td>
|
||||
<td>{{ item.updated_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>Noch keine Instanzen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.instance-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instance-form-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.instance-form,
|
||||
.instance-meta,
|
||||
.instance-table-wrap {
|
||||
border: 1px solid #d8e1e8;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.instance-form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instance-form input,
|
||||
.instance-form select {
|
||||
border: 1px solid #bcd0de;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.instance-form button {
|
||||
margin-top: 0.4rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
|
||||
}
|
||||
|
||||
.warn-text {
|
||||
margin: 0.2rem 0 0.1rem;
|
||||
color: #8b2a27;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.instance-domain-link {
|
||||
color: #0b5b89;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.instance-domain-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.inline-toggle-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-toggle-form + .inline-toggle-form {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-toggle-form button {
|
||||
border: 1px solid #bcd0de;
|
||||
background: #ffffff;
|
||||
color: #173f5a;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.68rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inline-toggle-form button:hover {
|
||||
background: #f2f8fc;
|
||||
}
|
||||
|
||||
.inline-toggle-form button.danger {
|
||||
border-color: #e3adad;
|
||||
color: #8b2a27;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.inline-toggle-form button.danger:hover {
|
||||
background: #fde4e4;
|
||||
}
|
||||
|
||||
.instance-meta h2,
|
||||
.instance-table-wrap h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.instance-meta p {
|
||||
margin: 0.28rem 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.instance-meta .note {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px dashed #c7d2db;
|
||||
}
|
||||
|
||||
.instance-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e7edf3;
|
||||
padding: 0.62rem;
|
||||
vertical-align: top;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.62rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
background: #eaf8f1;
|
||||
border: 1px solid #8cc9a6;
|
||||
color: #0f5c3a;
|
||||
}
|
||||
|
||||
.pill.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #e3adad;
|
||||
color: #8b2a27;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.instance-form-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const ownerSelect = document.getElementById('owner_username');
|
||||
const schoolInput = document.getElementById('school_name');
|
||||
if (!ownerSelect || !schoolInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSchoolName = () => {
|
||||
const selectedOption = ownerSelect.options[ownerSelect.selectedIndex];
|
||||
const schoolName = selectedOption ? (selectedOption.dataset.schoolName || '') : '';
|
||||
if (schoolName) {
|
||||
schoolInput.value = schoolName;
|
||||
}
|
||||
};
|
||||
|
||||
ownerSelect.addEventListener('change', updateSchoolName);
|
||||
updateSchoolName();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin | System-Tools{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="head">
|
||||
<h1>System-Tools</h1>
|
||||
<p>Nützliche Admin-Funktionen für Betrieb, Backup, Update, Restart und Log-Download.</p>
|
||||
</section>
|
||||
|
||||
<section class="core-tools">
|
||||
<h2>Core-Services</h2>
|
||||
<div class="actions">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="restart_core">
|
||||
<button type="submit">Website + MongoDB neu starten</button>
|
||||
</form>
|
||||
<a class="action-link" href="{{ url_for('admin_download_core_logs') }}">Core-Logs herunterladen</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="instance-tools">
|
||||
<h2>Instanz-Operationen</h2>
|
||||
{% if instances %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schule</th>
|
||||
<th>Subdomain</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in instances %}
|
||||
<tr>
|
||||
<td>{{ item.school_name }}</td>
|
||||
<td>{{ item.subdomain }}</td>
|
||||
<td>{{ item.status }} / {{ item.nginx_status }}</td>
|
||||
<td>
|
||||
<div class="actions row-actions">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="backup_instance">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<button type="submit">Backup</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="update_instance">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="restart_instance">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<button type="submit">Restart</button>
|
||||
</form>
|
||||
<a class="action-link" href="{{ url_for('admin_download_instance_logs', subdomain=item.subdomain) }}">Logs</a>
|
||||
</div>
|
||||
<form method="post" class="inline-admin-form">
|
||||
<input type="hidden" name="action" value="create_instance_admin">
|
||||
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
|
||||
<input type="text" name="admin_username" placeholder="Admin-Username" required>
|
||||
<input type="password" name="admin_password" placeholder="Passwort (>=8)" required>
|
||||
<input type="text" name="admin_first_name" placeholder="Vorname" value="Admin">
|
||||
<input type="text" name="admin_last_name" placeholder="Nachname" value="User">
|
||||
<button type="submit">Admin erstellen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>Keine Instanzen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.head { margin-bottom: 1rem; }
|
||||
.core-tools,
|
||||
.instance-tools {
|
||||
border: 1px solid #d8e1e8;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
|
||||
.row-actions { gap: 0.4rem; }
|
||||
.actions form { margin: 0; }
|
||||
.inline-admin-form {
|
||||
margin-top: 0.55rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.38rem;
|
||||
}
|
||||
.inline-admin-form input {
|
||||
border: 1px solid #bcd0de;
|
||||
border-radius: 8px;
|
||||
padding: 0.38rem 0.5rem;
|
||||
font: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
button,
|
||||
.action-link {
|
||||
border: 1px solid #bcd0de;
|
||||
background: #ffffff;
|
||||
padding: 0.42rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #173f5a;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
button:hover,
|
||||
.action-link:hover { background: #f2f8fc; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e7edf3;
|
||||
padding: 0.62rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.actions { display: grid; }
|
||||
.inline-admin-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -15,6 +15,7 @@
|
||||
<th>Benutzername</th>
|
||||
<th>Name</th>
|
||||
<th>Rolle</th>
|
||||
<th>Instanz-Links</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -24,6 +25,18 @@
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.display_name }}</td>
|
||||
<td>{{ 'Admin' if user.is_admin else 'Nutzer' }}</td>
|
||||
<td>
|
||||
{% set user_instances = instances_by_owner.get(user.username, []) %}
|
||||
{% if user_instances %}
|
||||
<div class="instance-links">
|
||||
{% for inst in user_instances %}
|
||||
<a href="https://{{ inst.domain }}" target="_blank" rel="noopener noreferrer">{{ inst.domain }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="no-instance">Keine zugewiesene Instanz</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" class="actions">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -385,6 +385,7 @@
|
||||
<div class="dropdown-menu">
|
||||
<a href="{{ url_for('my_licenses') }}">Meine Lizenzen</a>
|
||||
<a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
||||
<a href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
||||
<a href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
||||
<a href="{{ url_for('user_tickets') }}">Support Tickets</a>
|
||||
</div>
|
||||
@@ -401,8 +402,10 @@
|
||||
<a href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
|
||||
<a href="{{ url_for('admin_chats') }}">Admin-Chat</a>
|
||||
<a href="{{ url_for('admin_tickets') }}">Support-Center</a>
|
||||
<a href="{{ url_for('admin_system_tools') }}">System-Tools</a>
|
||||
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
|
||||
<a href="{{ url_for('admin_team') }}">Team Cards</a>
|
||||
<a href="{{ url_for('admin_instances') }}">Schul-Instanzen</a>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Meine Instanz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="instance-head">
|
||||
<h1>Meine Instanzverwaltung</h1>
|
||||
<p>Hier sehen Sie ausschließlich Ihre zugewiesene Instanz und können diese direkt nutzen.</p>
|
||||
</section>
|
||||
|
||||
<section class="instance-card">
|
||||
<h2>Instanzdaten</h2>
|
||||
{% if instance %}
|
||||
<p><strong>Schule:</strong> {{ instance.school_name }}</p>
|
||||
<p><strong>Domain:</strong> {{ instance.domain }}</p>
|
||||
<p><strong>Subdomain:</strong> {{ instance.subdomain }}</p>
|
||||
<p><strong>HTTPS-Port:</strong> {{ instance.https_port or '-' }}</p>
|
||||
<p><strong>Status:</strong> {{ instance.status }}</p>
|
||||
<p><strong>Nginx:</strong> {{ instance.nginx_status }}</p>
|
||||
<p><strong>Letzte Meldung:</strong> {{ instance.last_message }}</p>
|
||||
<p><strong>Aktualisiert:</strong> {{ instance.updated_at }}</p>
|
||||
<p><strong>Zugang:</strong> <a href="https://{{ instance.domain }}" target="_blank" rel="noopener noreferrer">https://{{ instance.domain }}</a></p>
|
||||
{% else %}
|
||||
<p>Für Ihr Konto ist aktuell keine Instanz zugewiesen.</p>
|
||||
<p>Bitte wenden Sie sich an den Administrator, um eine Instanz zugewiesen zu bekommen.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.instance-head { margin-bottom: 1rem; }
|
||||
.instance-card {
|
||||
max-width: 780px;
|
||||
border: 1px solid #d8e1e8;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
padding: 1rem;
|
||||
}
|
||||
.instance-card h2 { margin: 0 0 0.6rem; }
|
||||
.instance-card p { margin: 0.28rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
Executable
+475
@@ -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" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$http_port
|
||||
INVENTAR_HTTPS_PORT=$https_port
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
|
||||
EOF
|
||||
}
|
||||
|
||||
set_library_enabled() {
|
||||
local instance_dir="$1"
|
||||
local enabled="$2"
|
||||
local cfg="$instance_dir/config.json"
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
python3 - <<'PY' "$cfg" "$enabled"
|
||||
import json
|
||||
import sys
|
||||
|
||||
cfg_path = sys.argv[1]
|
||||
enabled = sys.argv[2] == "1"
|
||||
|
||||
with open(cfg_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
modules = data.get("modules")
|
||||
if not isinstance(modules, dict):
|
||||
modules = {}
|
||||
data["modules"] = modules
|
||||
|
||||
library = modules.get("library")
|
||||
if not isinstance(library, dict):
|
||||
library = {}
|
||||
modules["library"] = library
|
||||
|
||||
library["enabled"] = enabled
|
||||
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_instance_compose() {
|
||||
local compose_file="docker-compose.yml"
|
||||
[ -f "$compose_file" ] || return 0
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("docker-compose.yml")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
out = []
|
||||
in_nginx = False
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
|
||||
if stripped.startswith("nginx:") and indent == 2:
|
||||
in_nginx = True
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
|
||||
in_nginx = False
|
||||
|
||||
if stripped.startswith("container_name:") and indent == 4:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_nginx and stripped.startswith("ports:") and indent == 4:
|
||||
out.append(line)
|
||||
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
|
||||
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
nxt = lines[i]
|
||||
nxt_stripped = nxt.strip()
|
||||
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
|
||||
if nxt_stripped.startswith("-") and nxt_indent >= 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" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $full_domain;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $full_domain;
|
||||
|
||||
ssl_certificate $cert_file;
|
||||
ssl_certificate_key $key_file;
|
||||
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:$https_port;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_ssl_verify off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sfn "$avail_file" "$enabled_file"
|
||||
|
||||
if ! command -v nginx >/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"
|
||||
Reference in New Issue
Block a user