#!/usr/bin/env bash # ===================================================================== # cmk-manage.sh # Verwaltung des Checkmk-/NGINX-Proxy-Manager-Stacks: # Stack-Steuerung, Backup, Restore und Import (Migration) von Sites. # # Zwei Bedienarten: # 1) Parametergesteuert : ./scripts/cmk-manage.sh backup --site cmk # 2) ncurses-Oberflaeche: ./scripts/cmk-manage.sh gui # # Backups/Bundles sind immer .tar.gz # ===================================================================== set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" SELF="$SCRIPT_DIR/cmk-manage.sh" # shellcheck source=lib/common.sh . "$SCRIPT_DIR/lib/common.sh" BUNDLE_DIRNAME="cmkbundle" ASSUME_YES="" VERBOSE="" NPM_TOKEN="" ENVFILE_OPT="" # ===================================================================== # Hilfe # ===================================================================== usage() { cat <<'EOF' cmk-manage.sh - Checkmk Stack Manager (Docker & Podman) AUFRUF cmk-manage.sh [globale Optionen] [Optionen] GLOBALE OPTIONEN --env-file DATEI abweichende .env verwenden -y, --yes alle Rueckfragen automatisch mit "ja" beantworten -v, --verbose ausfuehrliche Ausgabe -h, --help diese Hilfe KOMMANDOS - Oberflaeche gui | menu ncurses-Oberflaeche starten (dialog/whiptail) KOMMANDOS - Stack up [--no-fetch] Stack starten (compose up -d). Fehlt das Image lokal, wird es zuvor ueber CMK_IMAGE_SOURCE geholt; --no-fetch unterdrueckt das down Stack stoppen und Container entfernen restart Stack neu starten pull Images aktualisieren status Status von Containern und Sites anzeigen logs [-f] [DIENST] Logs anzeigen firewall [check|open|close] [--apply] [--source CIDR] Firewall pruefen: welche Ports der Stack braucht und ob sie offen sind (firewalld, ufw, nftables, iptables). "close" schliesst wieder, was nicht mehr gebraucht wird - SSH niemals, fremde Freigaben bleiben unberuehrt selftest Skripte auf bekannte Fallstricke pruefen tui-check Testzeilen fuer die Rahmendarstellung ausgeben (hilft bei q/x/l/m statt Linien, etwa in PuTTY) shell Root-Shell im Checkmk-Container omd ARG... beliebiges omd-Kommando im Container ausfuehren KOMMANDOS - Sites sites Sites im Container auflisten site-start SITE Site starten site-stop SITE Site stoppen start-all-sites alle vorhandenen Sites starten (fuer Autostart nach Reboot) KOMMANDOS - Backup / Restore / Migration backup [Optionen] Backup einer/aller Sites als .tar.gz-Bundle erzeugen --site SITE Site (mehrfach moeglich) --all alle Sites sichern --dir VERZ Zielverzeichnis (Vorgabe: $BACKUP_DIR) --label TEXT Freitext-Kennzeichnung im Dateinamen --no-rrds Performance-Daten (RRDs) auslassen --no-logs Logdateien auslassen --stop Site waehrend des Backups stoppen (konsistenter) list vorhandene Backups/Bundles auflisten verify --file DATEI Bundle pruefen (Manifest + SHA256) restore [Optionen] Backup wiederherstellen --file DATEI Bundle oder rohes omd-Backup (.tar.gz) --as-site NAME unter anderem Site-Namen wiederherstellen (ACHTUNG: Agents muessen dann neu registriert werden!) --reuse vorhandene Site ueberschreiben --kill laufende Prozesse der Site beenden --no-start Site nach dem Restore nicht starten import [Optionen] Site von einer Direktinstallation uebernehmen (Bundle aus migrate_creator.sh) --file DATEI Migrations-Bundle (.tar.gz) --reuse vorhandene Site ueberschreiben --kill laufende Prozesse der Site beenden --set-default-site CMK_SITE_ID in der .env auf die importierte Site setzen --no-start Site nach dem Import nicht starten prune --keep N nur die N neuesten Backups behalten job list angelegte Backup-Jobs anzeigen job status Zeitplan, Cron-Datei und Schalter anzeigen job add --name N --cron "15 2 * * *" [Optionen] Job anlegen --sites ALL|a,b zu sichernde Sites (Vorgabe ALL) --dir VERZ Zielverzeichnis des Jobs (Vorgabe: $BACKUP_DIR) --keep N Anzahl Backups, die im Ziel bleiben (0 = alle) --no-rrds --no-logs --stop wie bei "backup" --disabled angelegt, aber noch nicht eingeplant job set NAME [Optionen] vorhandenen Job aendern job del NAME Job loeschen job enable|disable NAME Job ein-/ausschalten job run NAME Job sofort ausfuehren job run-all alle aktiven Jobs ausfuehren job auto [on|off] automatische Ausfuehrung per cron ein/aus KOMMANDOS - Checkmk-Image beschaffen image status konfiguriertes und lokal vorhandene Images anzeigen image list lokal vorhandene Checkmk-Images image versions [Opt.] verfuegbare Versionen auflisten (zum Kopieren) --edition E Edition (Vorgabe: CMK_EDITION) --source registry|download|web|all Quelle (Vorgabe all) web = oeffentliches Versionsarchiv von checkmk.com, funktioniert ohne Zugangsdaten --filter TEXT nur Versionen, die TEXT enthalten, z.B. 2.3.0 --limit N hoechstens N Eintraege (Vorgabe 25), --all fuer alle --user U --password P statt der Werte aus der .env --bare nur die nackte Liste, ohne Rahmen image why [REF] erklaeren, warum sich eine Referenz nicht holen laesst (ohne REF: die aus CMK_IMAGE) image repos [Opt.] herausfinden, welche Repositories die Registry wirklich anbietet (Katalog + uebliche Pfade) image test [Opt.] Zugangsdaten pruefen, ohne etwas herunterzuladen --edition E Edition, deren Repository geprueft wird --version V Version fuer die Pruefung des Downloadportals --user U --password P statt der Werte aus der .env --only registry|download nur einen Bezugsweg pruefen image get [Opt.] Image ueber den eingestellten Bezugsweg holen --source registry|download|auto (Vorgabe: CMK_IMAGE_SOURCE) auto = erst Registry, dann Downloadportal --edition E --version V sonst aus der .env image pull [Opt.] Image aus einer Registry holen --edition E raw|community|free|cloud|managed|pro|ultimate| ultimatemt|enterprise (Vorgabe: CMK_EDITION) --version V z.B. 2.3.0p23 (Vorgabe: CMK_VERSION) --registry R abweichende Registry --user U --password P Zugangsdaten (Enterprise: Kundenportal) --image REF fertige Referenz statt Edition/Version --remember CMK_EDITION und CMK_VERSION mit in die .env schreiben --no-set .env nicht veraendern image download [Opt.] Image-Tarball vom Downloadportal holen und einspielen --edition/--version/--user/--password wie oben --url URL vollstaendige URL fest vorgeben --dir VERZ Ablage (Vorgabe: $IMAGE_DIR) --keep Tarball nach dem Einspielen behalten --remember CMK_EDITION und CMK_VERSION mit in die .env schreiben image load --file F bereits heruntergeladenen Tarball einspielen image use REF [--remember] Image als CMK_IMAGE eintragen; mit --remember auch CMK_EDITION und CMK_VERSION daraus ableiten image use --edition E --version V umgekehrt: die Referenz aus Edition und Version bilden und eintragen, OHNE etwas herunterzuladen. Geholt wird sie beim naechsten "up". KOMMANDOS - Konfiguration config list [--category KAT] alle Einstellungen mit aktuellen Werten config get KEY einzelnen Wert ausgeben config set KEY WERT Wert setzen (mit Pruefung) config edit .env im Editor oeffnen npm-port [PORT] Port der Proxy-Manager-Oberflaeche (ohne PORT: aktuellen Stand anzeigen) npm-reset Datenbank des Proxy Managers beiseitelegen, damit das Konto beim naechsten Start neu aus NPM_ADMIN_EMAIL/-PASSWORD entsteht (Zertifikate bleiben erhalten) npm-enable [on|off] NGINX Proxy Manager ein-/ausschalten. "off" stoppt und entfernt den Container, die Daten unter ./data/npm bleiben erhalten (fuer den Fall, dass ein anderer NGINX die HTTPS-Terminierung uebernimmt) KOMMANDOS - Lokale Bedienung ohne externen Zugriff browser [Optionen] Terminal-Browser im Container starten, um den NGINX Proxy Manager direkt auf der Maschine einzurichten (Host-Netzwerk, erreicht 127.0.0.1:81) -e, --engine E browsh (Vorgabe) | carbonyl | links | w3m | lynx | shell browsh/carbonyl koennen JavaScript - die Textbrowser NICHT, damit laesst sich der Proxy Manager nicht bedienen -u, --url URL Startseite (Vorgabe: http://127.0.0.1:81) --build Werkzeug-Image neu bauen --pull Engine-Image neu laden KOMMANDOS - Proxy-Hosts (ueber die API des Proxy Managers) proxy status Anmeldung pruefen, Anzahl Hosts/Zertifikate proxy list vorhandene Proxy-Hosts proxy certs vorhandene Zertifikate proxy add [Opt.] Proxy-Host anlegen --domain FQDN oeffentlicher Name (Vorgabe: PROXY_DOMAIN) --forward-host H Ziel (Vorgabe 127.0.0.1) --forward-port P Zielport (Vorgabe 5000 = Checkmk) --scheme http|https --cert self|letsencrypt|none (Vorgabe self) --update vorhandenen Host ersetzen --advanced TEXT eigener nginx-Abschnitt --location /P=HOST:PORT weiterer Pfad auf demselben Namen, mehrfach angebbar. Damit lassen sich mehrere Dienste hinter EINEM Port unterscheiden: --location /grafana=127.0.0.1:3000 --no-force-ssl --no-http2 --no-websockets --no-block-exploits proxy show --domain X | --id N Einzelheiten samt Pfaden anzeigen proxy del --id N Proxy-Host loeschen KOMMANDOS - Zertifikat cert [Optionen] selbstsigniertes Zertifikat fuer den Proxy erzeugen --domain FQDN Common Name / SAN --alt NAME zusaetzlicher SAN (mehrfach; DNS-Name oder IP) --days N Gueltigkeit in Tagen (Vorgabe 3650) BEISPIELE ./scripts/cmk-manage.sh up ./scripts/cmk-manage.sh backup --all --no-logs ./scripts/cmk-manage.sh import --file backups/cmkmigrate_prod_2.3.0p23_*.tar.gz --set-default-site ./scripts/cmk-manage.sh image test --edition enterprise --version 2.3.0p23 ./scripts/cmk-manage.sh image pull --edition enterprise --version 2.3.0p23 \ --user --password ./scripts/cmk-manage.sh image download --edition enterprise --version 2.3.0p23 ./scripts/cmk-manage.sh image load --file images/check-mk-enterprise-2.3.0p23.docker.tar.gz ./scripts/cmk-manage.sh npm-port 10443 # Adminoberflaeche auf Port 10443 ./scripts/cmk-manage.sh config set CMK_SITE_ID prod ./scripts/cmk-manage.sh browser # Proxy Manager lokal bedienen ./scripts/cmk-manage.sh gui EOF } # ===================================================================== # Bundle-Format # # .tar.gz # cmkbundle/MANIFEST key=value, per "source" lesbar # cmkbundle/INFO.txt menschenlesbare Zusammenfassung # cmkbundle/site.tar.gz das eigentliche "omd backup"-Archiv # cmkbundle/site.tar.gz.sha256 # ===================================================================== edition_from_version() { case "$1" in *.cre) echo "Raw Edition" ;; *.cee) echo "Enterprise Edition" ;; *.cme) echo "Managed Services Edition" ;; *.cce) echo "Cloud Edition" ;; *) echo "unbekannt" ;; esac } # Manifest eines Bundles einlesen -> Variablen B_* bundle_read() { local file="$1" B_SITE=""; B_CMK_VERSION=""; B_SOURCE_KIND=""; B_SOURCE_HOST="" B_SOURCE_IP=""; B_CREATED=""; B_ARCHIVE_SHA256=""; B_LABEL="" B_BUNDLE_TYPE=""; B_ARCHIVE_SIZE=""; B_OMD_BACKUP_OPTS=""; B_SOURCE_OS="" B_IS_BUNDLE=0 # ACHTUNG: "grep -q" beendet sich beim ersten Treffer. Der Erzeuger # bekommt dann SIGPIPE, und unter "set -o pipefail" gilt die ganze # Pipe als fehlgeschlagen - aber nur, wenn er noch am Schreiben war, # also abhaengig von der Datenmenge. Bei einem grossen Archiv wuerde # ein vorhandenes Bundle dadurch als "kein Bundle" gelten. Deshalb # solche Pipes immer in einer Subshell ohne pipefail ausfuehren. if ( set +o pipefail; tar -tzf "$file" 2>/dev/null | grep -qx "$BUNDLE_DIRNAME/MANIFEST" ); then B_IS_BUNDLE=1 local mf; mf="$(mktemp)" tar -xzOf "$file" "$BUNDLE_DIRNAME/MANIFEST" >"$mf" # nur bekannte Schluessel uebernehmen (kein blindes eval) local k v while IFS='=' read -r k v; do case "$k" in SITE) B_SITE="$v" ;; CMK_VERSION) B_CMK_VERSION="$v" ;; SOURCE_KIND) B_SOURCE_KIND="$v" ;; SOURCE_HOST) B_SOURCE_HOST="$v" ;; SOURCE_IP) B_SOURCE_IP="$v" ;; SOURCE_OS) B_SOURCE_OS="$v" ;; CREATED) B_CREATED="$v" ;; ARCHIVE_SHA256) B_ARCHIVE_SHA256="$v" ;; ARCHIVE_SIZE) B_ARCHIVE_SIZE="$v" ;; LABEL) B_LABEL="$v" ;; BUNDLE_TYPE) B_BUNDLE_TYPE="$v" ;; OMD_BACKUP_OPTS) B_OMD_BACKUP_OPTS="$v" ;; esac done <"$mf" rm -f "$mf" else # rohes omd-Backup: Site-Name ist die erste Pfadkomponente. # Ein omd-Archiv beginnt immer mit "/..." - alles andere ist # kein Checkmk-Backup und wird abgewiesen. local first first="$(tar -tzf "$file" 2>/dev/null | head -n1)" case "$first" in */*) B_SITE="${first%%/*}" ;; *) return 1 ;; esac B_BUNDLE_TYPE="raw" B_SOURCE_KIND="unbekannt" fi [ -n "$B_SITE" ] || return 1 } bundle_show() { local file="$1" bundle_read "$file" || { echo "Kein gueltiges Checkmk-Backup: $file"; return 1; } cat </dev/null || die "Archiv ist beschaedigt oder kein gueltiges tar.gz." bundle_read "$file" || die "Im Archiv wurde keine Checkmk-Site gefunden." if [ "$B_IS_BUNDLE" = 1 ] && [ -n "$B_ARCHIVE_SHA256" ]; then log "Pruefe SHA256 der Nutzdaten ..." local sum sum="$(tar -xzOf "$file" "$BUNDLE_DIRNAME/site.tar.gz" | sha256sum | awk '{print $1}')" [ "$sum" = "$B_ARCHIVE_SHA256" ] || die "Pruefsumme falsch! erwartet : $B_ARCHIVE_SHA256 ermittelt: $sum" ok "Pruefsumme korrekt." else warn "Kein Manifest/keine Pruefsumme vorhanden - nur Strukturpruefung." fi bundle_show "$file" ok "Bundle ist in Ordnung." } # Nutzdaten (omd-Archiv) aus einem Bundle nach stdout schreiben bundle_extract_payload() { local file="$1" if ( set +o pipefail; tar -tzf "$file" 2>/dev/null | grep -qx "$BUNDLE_DIRNAME/site.tar.gz" ); then tar -xzOf "$file" "$BUNDLE_DIRNAME/site.tar.gz" else cat "$file" fi } # ===================================================================== # Checkmk-Image beschaffen # # Hintergrund: # * Die Enterprise-Edition liegt NICHT auf Docker Hub. Dort gibt es nur # raw / community / cloud / managed / pro / ultimate / ultimatemt. # Enterprise kommt aus registry.checkmk.com (Login) oder als Tarball # vom Checkmk-Downloadportal (HTTP-Basic-Auth). # * Ein heruntergeladener Image-Tarball wird mit "load" eingespielt, # NICHT mit "import". "import" erwartet ein Dateisystem-Archiv und # erzeugt ein Image ohne Metadaten (ohne Entrypoint, ohne Tag) - # danach "kennt" die Runtime das Image scheinbar nicht. # * Nach dem Laden traegt dieses Skript den TATSAECHLICHEN, voll- # qualifizierten Namen in CMK_IMAGE der .env ein. # ===================================================================== edition_repo() { # Edition -> vollqualifiziertes Repository case "$1" in raw) echo "docker.io/checkmk/check-mk-raw" ;; community) echo "docker.io/checkmk/check-mk-community" ;; free) echo "docker.io/checkmk/check-mk-free" ;; cloud) echo "docker.io/checkmk/check-mk-cloud" ;; managed) echo "docker.io/checkmk/check-mk-managed" ;; pro) echo "docker.io/checkmk/check-mk-pro" ;; ultimate) echo "docker.io/checkmk/check-mk-ultimate" ;; ultimatemt) echo "docker.io/checkmk/check-mk-ultimatemt" ;; enterprise) echo "${CMK_REGISTRY:-registry.checkmk.com}/enterprise/check-mk-enterprise" ;; *) return 1 ;; esac } edition_known() { edition_repo "$1" >/dev/null 2>&1; } # Braucht diese Edition eine Anmeldung an der Registry? edition_needs_login() { [ "$1" = enterprise ]; } edition_pkgname() { echo "check-mk-$1"; } # Lokal vorhandene Checkmk-Images image_local_list() { $RUNTIME images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \ | grep -Ei 'check-?mk' | grep -v '' | sort -u || true } image_exists_local() { $RUNTIME image inspect "$1" >/dev/null 2>&1 } image_is_short_name() { # Kurzer Name = ohne Registry-Anteil. Podman muesste ihn dann ueber # unqualified-search-registries erraten. case "$1" in */*) : ;; *) return 0 ;; # gar kein "/" -> immer kurz esac case "${1%%/*}" in *.*|*:*|localhost) return 1 ;; # Registry mit Punkt, Port oder localhost *) return 0 ;; esac } image_set_env() { local ref="$1" env_set CMK_IMAGE "$ref" ok "CMK_IMAGE=$ref in der .env eingetragen." if image_is_short_name "$ref"; then warn "'$ref' ist ein KURZER Name ohne Registry. Podman wuerde ihn ueber unqualified-search-registries aufloesen (auf RHEL zuerst registry.access.redhat.com). Besser vollqualifiziert angeben." fi log "Danach den Stack neu erstellen: $SELF up" } # Vor/Nachher-Vergleich, um den Namen eines geladenen Images sicher zu finden image_snapshot() { $RUNTIME images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | sort -u || true; } # Tarball einspielen -> gibt den geladenen Image-Namen auf stdout aus image_load_file() { local file="$1" [ -f "$file" ] || die "Datei nicht gefunden: $file" # Ist das ueberhaupt ein Container-Image-Archiv (docker-archive/OCI)? if ! ( set +o pipefail; tar -tzf "$file" 2>/dev/null | grep -qE '^(manifest\.json|oci-layout|index\.json)$' ); then if ( set +o pipefail; tar -tf "$file" 2>/dev/null | grep -qE '^(manifest\.json|oci-layout|index\.json)$' ); then : else err "'$file' enthaelt kein manifest.json / oci-layout." cat >&2 <<'EOF' Das ist damit KEIN Container-Image-Archiv, sondern vermutlich ein normales tar-Archiv. Nur "docker save"/"podman save"-Archive bzw. die Image-Tarballs von Checkmk lassen sich mit "load" einspielen. Haeufige Verwechslung: podman import -> erwartet ein Dateisystem-Archiv (rootfs), erzeugt ein Image OHNE Entrypoint und ohne Tag podman load -> spielt ein echtes Image inkl. Metadaten ein <-- richtig EOF return 1 fi fi local before after loaded before="$(image_snapshot)" log "Spiele Image ein: $RUNTIME load -i $file (das dauert einen Moment) ..." >&2 local out out="$($RUNTIME load -i "$file" 2>&1)" || { printf '%s\n' "$out" >&2; return 1; } printf '%s\n' "$out" >&2 # 1. Versuch: Ausgabe auswerten # docker: "Loaded image: repo:tag" podman: "Loaded image(s): repo:tag" loaded="$(printf '%s\n' "$out" | sed -nE 's/^Loaded image(\(s\))?:[[:space:]]*//p' | head -n1 | tr -d '\r')" # 2. Versuch: Differenz der Image-Liste (bevorzugt ein Checkmk-Image) if [ -z "$loaded" ] || [ "${loaded#sha256:}" != "$loaded" ]; then after="$(image_snapshot)" local diff_new diff_new="$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | grep -v '')" local cand cand="$(printf '%s\n' "$diff_new" | grep -Ei 'check-?mk' | head -n1)" [ -n "$cand" ] || cand="$(printf '%s\n' "$diff_new" | sed '/^$/d' | head -n1)" if [ -n "$cand" ]; then loaded="$cand"; fi fi if [ -z "$loaded" ]; then err "Der Name des geladenen Images konnte nicht ermittelt werden." err "Bitte manuell nachsehen: $RUNTIME images" return 1 fi if [ "${loaded#sha256:}" != "$loaded" ]; then err "Das Archiv enthaelt keinen Image-Namen (nur die ID $loaded)." err "Manuell benennen: $RUNTIME tag $loaded localhost/checkmk/check-mk-enterprise:" return 1 fi printf '%s\n' "$loaded" } # Datei mit HTTP-Basic-Auth holen (Zugangsdaten nicht in der Prozessliste) image_fetch_url() { local url="$1" out="$2" user="$3" pass="$4" local host; host="$(printf '%s' "$url" | sed -E 's|^[a-z]+://([^/]+).*|\1|')" if command -v curl >/dev/null 2>&1; then # Erst nachsehen, was der Server sagt - dann ist im Fehlerfall # klar, ob es an der Anmeldung (401) oder am Namen (404) liegt. local code; code="$(http_code "$url" "$user" "$pass")" case "$code" in 200) : ;; 401|403) warn "Anmeldung abgelehnt (HTTP $code)" printf ' %s\n' "$url" >&2 log "Zugangsdaten pruefen: $SELF image test --only download" return 1 ;; 404) log "Nicht vorhanden (HTTP 404): $(basename "$url")"; return 1 ;; 000) warn "Nicht erreichbar: $url"; return 1 ;; *) warn "Unerwartete Antwort (HTTP $code): $url"; return 1 ;; esac log "Lade $url" if curl_auth "$user" "$pass" -fL --progress-bar -o "$out" "$url"; then return 0 fi return 1 elif command -v wget >/dev/null 2>&1; then warn "curl fehlt - verwende wget (die Zugangsdaten sind dabei kurzzeitig in der Prozessliste sichtbar)." local -a wa=() if [ -n "$user" ]; then wa=(--user="$user" --password="$pass"); fi wget ${wa[@]+"${wa[@]}"} -O "$out" "$url" return $? else die "Weder curl noch wget vorhanden." fi } # --------------------------------------------------------------------- # Versionsarchiv von checkmk.com # # https://checkmk.com/download/archive ist serverseitig gerendert und # braucht KEINE Zugangsdaten. Je Version steht dort # data-handle="2.3.0p9" data-versions="{...}" # und im JSON die echten Dateinamen je Edition. Daran haengt auch die # Aufloesung der Verwirrung um die Editionsnamen: die Website benennt # 2.3.0p9 als "pro", die Datei heisst weiterhin # check-mk-enterprise-docker-2.3.0p9.tar.gz # --------------------------------------------------------------------- ARCHIVE_CACHE="" archive_fetch() { # Seite einmal je Aufruf holen, Pfad auf stdout if [ -n "$ARCHIVE_CACHE" ] && [ -s "$ARCHIVE_CACHE" ]; then printf '%s\n' "$ARCHIVE_CACHE"; return 0 fi command -v curl >/dev/null 2>&1 || return 1 local f; f="$(mktemp)" if curl -sS -L --max-time 60 "${CMK_ARCHIVE_URL:-https://checkmk.com/download/archive}" -o "$f" 2>/dev/null \ && [ -s "$f" ]; then ARCHIVE_CACHE="$f"; printf '%s\n' "$f"; return 0 fi rm -f "$f"; return 1 } archive_versions() { # alle im Archiv gelisteten Versionen local f; f="$(archive_fetch)" || return 1 grep -oE 'data-handle="[0-9]+\.[0-9]+\.[0-9]+[a-z0-9.]*"' "$f" \ | sed -E 's/data-handle="([^"]*)"/\1/' | sort -u } # Exakten Dateinamen des Docker-Tarballs ermitteln archive_docker_file() { # edition version -> Dateiname auf stdout local edition="$1" version="$2" local f; f="$(archive_fetch)" || return 1 # Abschnitt dieser Version herausschneiden und die Docker-Dateinamen lesen local names names="$(grep -oE "data-handle=\"$version\"[^>]*" "$f" \ | grep -oE "check-mk-[a-z]+-docker-${version}\.tar\.gz" | sort -u)" [ -n "$names" ] || return 1 local ed for ed in $(edition_aliases "$edition"); do if ( set +o pipefail; printf '%s\n' "$names" | grep -qx "check-mk-${ed}-docker-${version}.tar.gz" ); then printf 'check-mk-%s-docker-%s.tar.gz\n' "$ed" "$version"; return 0 fi done return 1 } # Dateinamen der Image-Tarballs im Downloadportal. # Bestaetigt am echten Server: # .../checkmk/2.3.0p9/check-mk-raw-docker-2.3.0p9.tar.gz -> 200 # .../checkmk/2.3.0p9/check-mk-enterprise-docker-2.3.0p9.tar.gz -> 401 (geschuetzt) # .../checkmk/2.3.0p9/check-mk-raw-2.3.0p9.docker.tar.gz -> 404 # Zusaetzlich die Editionsnamen ab 2.5 mitprobieren, damit derselbe # Aufruf auch fuer neuere Versionen funktioniert. edition_aliases() { case "$1" in enterprise) printf '%s\n' enterprise pro ;; pro) printf '%s\n' pro enterprise ;; raw) printf '%s\n' raw community ;; community) printf '%s\n' community raw ;; managed) printf '%s\n' managed ultimatemt ;; ultimatemt) printf '%s\n' ultimatemt managed ;; *) printf '%s\n' "$1" ;; esac } download_url_candidates() { # edition version -> URLs, beste zuerst local edition="$1" version="$2" ed local base="${CMK_DOWNLOAD_BASEURL:-https://download.checkmk.com/checkmk}" # 1. Wahl: der im Archiv hinterlegte, echte Dateiname local exact if exact="$(archive_docker_file "$edition" "$version" 2>/dev/null)" && [ -n "$exact" ]; then printf '%s/%s/%s ' "$base" "$version" "$exact" fi for ed in $(edition_aliases "$edition"); do printf '%s/%s/check-mk-%s-docker-%s.tar.gz\n' "$base" "$version" "$ed" "$version" done # aelteres Schema, zur Sicherheit for ed in $(edition_aliases "$edition"); do printf '%s/%s/check-mk-%s-%s.docker.tar.gz\n' "$base" "$version" "$ed" "$version" done } # Kandidaten ohne Dopplungen - der aus dem Archiv gelesene Name ist oft # derselbe wie der erste erzeugte; sonst wuerde er zweimal probiert. download_url_list() { # edition version download_url_candidates "$1" "$2" | awk '!seen[$0]++' } # --------------------------------------------------------------------- # image pull - aus einer Registry holen # --------------------------------------------------------------------- image_cmd_pull() { local edition="${CMK_EDITION:-raw}" version="${CMK_VERSION:-}" registry="${CMK_REGISTRY:-}" local user="${CMK_REGISTRY_USER:-}" pass="${CMK_REGISTRY_PASSWORD:-}" ref="" setenv=1 while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; --registry) registry="$2"; shift 2 ;; --user) user="$2"; shift 2 ;; --password) pass="$2"; shift 2 ;; --image) ref="$2"; shift 2 ;; --no-set) setenv=""; shift ;; --remember) remember=1; shift ;; *) die "Unbekannte Option fuer 'image pull': $1" ;; esac done if [ -z "$ref" ]; then edition_known "$edition" || die "Unbekannte Edition '$edition'. Moeglich: raw community free cloud managed pro ultimate ultimatemt enterprise" [ -n "$version" ] || die "Keine Version angegeben (--version oder CMK_VERSION)." CMK_REGISTRY="${registry:-${CMK_REGISTRY:-registry.checkmk.com}}" ref="$(edition_repo "$edition"):$version" fi local reg="${ref%%/*}" if edition_needs_login "$edition" || [ -n "$user" ]; then if [ -z "$user" ]; then err "Fuer '$edition' ist eine Anmeldung an $reg noetig." cat >&2 < --password EOF return 1 fi log "Melde an $reg an ..." printf '%s' "$pass" | $RUNTIME login --username "$user" --password-stdin "$reg" \ || die "Anmeldung an $reg fehlgeschlagen." ok "Anmeldung erfolgreich." fi log "Hole Image: $ref" if ! $RUNTIME pull "$ref"; then err "Das Image '$ref' konnte nicht geholt werden." if [ -n "${IMAGE_QUIET_FAIL:-}" ]; then return 1; fi if [ "$reg" != "docker.io" ] || [ -n "$user" ]; then image_diagnose_ref "$ref" || true return 1 fi if [ "$edition" = enterprise ]; then cat >&2 <<'EOF' Hinweis: die Enterprise-Edition gibt es NICHT auf Docker Hub. Zwei Wege stehen zur Verfuegung: a) private Registry: cmk-manage.sh image pull --edition enterprise --version 2.3.0p23 \ --user --password b) Tarball vom Downloadportal: cmk-manage.sh image download --edition enterprise --version 2.3.0p23 \ --user --password Ab Checkmk 2.5 heisst diese Edition "pro" und liegt frei auf Docker Hub: cmk-manage.sh image pull --edition pro --version 2.5.0p11 EOF fi return 1 fi ok "Image vorhanden: $ref" if [ -n "$remember" ]; then env_set CMK_EDITION "$edition"; env_set CMK_VERSION "$version" log "CMK_EDITION=$edition und CMK_VERSION=$version uebernommen." fi [ -n "$setenv" ] && image_set_env "$ref" return 0 } # --------------------------------------------------------------------- # image download - Tarball vom Downloadportal holen und einspielen # --------------------------------------------------------------------- image_cmd_download() { local edition="${CMK_EDITION:-raw}" version="${CMK_VERSION:-}" local user="${CMK_DOWNLOAD_USER:-}" pass="${CMK_DOWNLOAD_PASSWORD:-}" local url="${CMK_DOWNLOAD_URL:-}" dir="" keep="" setenv=1 remember="" while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; --url) url="$2"; shift 2 ;; --user) user="$2"; shift 2 ;; --password) pass="$2"; shift 2 ;; --dir) dir="$2"; shift 2 ;; --keep) keep=1; shift ;; --no-set) setenv=""; shift ;; --remember) remember=1; shift ;; *) die "Unbekannte Option fuer 'image download': $1" ;; esac done dir="${dir:-${IMAGE_DIR:-$PROJECT_DIR/images}}" case "$dir" in /*) : ;; *) dir="$PROJECT_DIR/${dir#./}" ;; esac mkdir -p "$dir" local -a urls=() if [ -n "$url" ]; then urls=("$url") else [ -n "$version" ] || die "Keine Version angegeben (--version oder CMK_VERSION)." case "$version" in *-latest|latest) die "Fuer den Tarball-Download wird eine exakte Version benoetigt, z.B. 2.3.0p23 - '$version' funktioniert dort nicht." ;; esac edition_known "$edition" || die "Unbekannte Edition '$edition'." mapfile -t urls < <(download_url_list "$edition" "$version") fi local target="" u for u in "${urls[@]}"; do target="$dir/$(basename "$u")" if image_fetch_url "$u" "$target" "$user" "$pass"; then ok "Heruntergeladen: $target ($(human_size "$(file_size "$target")"))" break fi rm -f "$target"; target="" done if [ -z "$target" ]; then err "Der Image-Tarball konnte nicht geladen werden." cat >&2 </' \\ --user --password * oder die Datei manuell im Kundenportal herunterladen und einspielen: $SELF image load --file /pfad/zur/datei.tar.gz EOF return 1 fi local loaded loaded="$(image_load_file "$target")" || return 1 ok "Image eingespielt: $loaded" if [ -n "$remember" ]; then env_set CMK_EDITION "$edition"; env_set CMK_VERSION "$version" log "CMK_EDITION=$edition und CMK_VERSION=$version uebernommen." fi [ -z "$keep" ] && { rm -f "$target"; log "Tarball entfernt (mit --keep behalten)."; } [ -n "$setenv" ] && image_set_env "$loaded" return 0 } # --------------------------------------------------------------------- # image load - bereits heruntergeladenen Tarball einspielen # --------------------------------------------------------------------- image_cmd_load() { local file="" setenv=1 while [ $# -gt 0 ]; do case "$1" in --file) file="$2"; shift 2 ;; --no-set) setenv=""; shift ;; *) [ -z "$file" ] && { file="$1"; shift; } || die "Unbekannte Option: $1" ;; esac done [ -n "$file" ] || die "--file fehlt." local loaded loaded="$(image_load_file "$file")" || return 1 ok "Image eingespielt: $loaded" [ -n "$setenv" ] && image_set_env "$loaded" } # --------------------------------------------------------------------- # image use / list / status # --------------------------------------------------------------------- image_cmd_use() { local ref="" remember="" edition="" version="" while [ $# -gt 0 ]; do case "$1" in --remember) remember=1; shift ;; --edition) edition="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; *) if [ -z "$ref" ]; then ref="$1"; shift; else die "Unbekannte Option: $1"; fi ;; esac done # Ohne fertige Referenz laesst sie sich aus Edition und Version # bilden. Dafuer muss nichts heruntergeladen werden - die Runtime # holt das Image beim naechsten Start selbst. if [ -z "$ref" ] && [ -n "$edition" ]; then edition_known "$edition" || die "Unbekannte Edition '$edition'." [ -n "$version" ] || die "--version fehlt." ref="$(edition_repo "$edition"):$version" fi [ -n "$ref" ] || die "Image-Referenz fehlt. Entweder direkt: $SELF image use localhost/checkmk/check-mk-enterprise:2.3.0p23 oder aus Edition und Version gebildet: $SELF image use --edition enterprise --version 2.4.0p9" image_exists_local "$ref" || log "'$ref' ist lokal noch nicht vorhanden - wird beim naechsten '$SELF up' geholt." image_set_env "$ref" # Edition und Version mitfuehren, damit die Vorgaben nicht dauerhaft # etwas anderes behaupten als das benutzte Image. if [ -n "$edition" ]; then env_set CMK_EDITION "$edition"; ok "CMK_EDITION=$edition" env_set CMK_VERSION "$version"; ok "CMK_VERSION=$version" elif [ -n "$remember" ]; then local tag repo name ed tag="${ref##*:}"; repo="${ref%:*}" case "$repo" in */*) name="${repo##*/}" ;; *) name="$repo" ;; esac ed="${name#check-mk-}" if [ "$ed" != "$name" ] && edition_known "$ed"; then env_set CMK_EDITION "$ed"; ok "CMK_EDITION=$ed" else warn "Edition liess sich aus '$ref' nicht ableiten - CMK_EDITION bleibt." fi if [ "$tag" != "$ref" ] && [ -n "$tag" ]; then env_set CMK_VERSION "$tag"; ok "CMK_VERSION=$tag" fi fi } image_cmd_list() { printf '%sLokal vorhandene Checkmk-Images%s\n' "$C_BLD" "$C_RST" local l n=0 while IFS= read -r l; do [ -n "$l" ] || continue if [ "$l" = "${CMK_IMAGE:-}" ]; then printf ' %s %s<- laut .env in Verwendung%s\n' "$l" "$C_GRN" "$C_RST" else printf ' %s\n' "$l" fi n=$((n+1)) done < <(image_local_list) [ "$n" -eq 0 ] && echo " (keines)" return 0 } # Passt CMK_IMAGE zu CMK_EDITION/CMK_VERSION? # # CMK_IMAGE ist der einzige Wert, den compose und die systemd-Units # verwenden. CMK_EDITION und CMK_VERSION sind nur Eingaben fuer den # Downloader. Wer sie aendert, ohne anschliessend ein Image zu holen, # betreibt weiter das alte - das faellt sonst nirgends auf. # # Rueckgabe: 0 = stimmig (oder nicht beurteilbar), 1 = Abweichung. # Die Erklaerung geht nach stdout. image_consistency_check() { local img="${CMK_IMAGE:-}" ed="${CMK_EDITION:-}" ver="${CMK_VERSION:-}" [ -n "$img" ] && [ -n "$ed" ] || return 0 local repo tag name tag="${img##*:}" repo="${img%:*}" case "$repo" in */*) name="${repo##*/}" ;; *) name="$repo" ;; esac # kein Tag angegeben -> nicht beurteilbar if [ "$tag" = "$img" ]; then tag=""; fi local ed_ok="" a for a in $(edition_aliases "$ed"); do if [ "$name" = "check-mk-$a" ]; then ed_ok=1; fi done local ver_ok=1 [ -n "$ver" ] && [ -n "$tag" ] && [ "$tag" != "$ver" ] && ver_ok="" [ -n "$ed_ok" ] && [ -n "$ver_ok" ] && return 0 printf 'CMK_IMAGE passt nicht zu den eingestellten Vorgaben.\n\n' printf ' verwendet wird : %s\n' "$img" [ -z "$ed_ok" ] && printf ' Edition : eingestellt "%s", im Image steckt "%s"\n' \ "$ed" "${name#check-mk-}" [ -z "$ver_ok" ] && printf ' Version : eingestellt "%s", im Image steckt "%s"\n' "$ver" "$tag" printf '\nCompose und die systemd-Units verwenden ausschliesslich CMK_IMAGE.\n' printf 'Edition und Version sind nur Vorgaben fuer den Downloader - sie\n' printf 'wirken erst, wenn daraus ein Image geholt wird:\n\n' printf ' %s image pull --edition %s --version %s\n' "$SELF" "$ed" "${ver:-}" printf ' %s up\n' "$SELF" return 1 } image_cmd_status() { local msg printf '\n%sKonfiguriertes Image (.env: CMK_IMAGE)%s\n' "$C_BLD" "$C_RST" printf ' %s\n' "${CMK_IMAGE:-}" if [ -n "${CMK_IMAGE:-}" ]; then if image_exists_local "$CMK_IMAGE"; then printf ' Status : %slokal vorhanden%s\n' "$C_GRN" "$C_RST" else printf ' Status : %snicht lokal vorhanden%s (wird beim Start geholt)\n' "$C_YEL" "$C_RST" fi if image_is_short_name "$CMK_IMAGE"; then printf ' %sACHTUNG%s: kurzer Name ohne Registry - unter Podman fehleranfaellig.\n' "$C_RED" "$C_RST" fi fi if ! msg="$(image_consistency_check)"; then printf '\n' warn "$msg" fi printf '\n%sVorgaben fuer den Downloader%s\n' "$C_BLD" "$C_RST" printf ' Edition : %s -> %s\n' "${CMK_EDITION:-raw}" "$(edition_repo "${CMK_EDITION:-raw}" 2>/dev/null || echo '?')" printf ' Version : %s\n' "${CMK_VERSION:-}" printf ' Registry : %s (Benutzer: %s)\n' "${CMK_REGISTRY:-registry.checkmk.com}" "${CMK_REGISTRY_USER:-}" printf ' Download : %s (Benutzer: %s)\n' "${CMK_DOWNLOAD_BASEURL:-https://download.checkmk.com/checkmk}" "${CMK_DOWNLOAD_USER:-}" echo image_cmd_list echo } # --------------------------------------------------------------------- # Zugangsdaten pruefen # # Testet, ohne etwas herunterzuladen oder eine Anmeldung zu speichern: # 1. Registry (registry.checkmk.com): Docker-Registry-v2-Anmeldung und # ob das Konto Zugriff auf das Enterprise-Repository hat. # 2. Downloadportal: HTTP-Basic-Anmeldung auf die Image-Tarballs. # Die Zugangsdaten gehen ueber "curl --config -" an curl und tauchen # damit nicht in der Prozessliste auf. # --------------------------------------------------------------------- # Zugangsdaten an curl uebergeben, ohne sie in die Prozessliste zu # schreiben. # # Frueher lief das ueber eine temporaere .netrc. Deren Format trennt die # Felder durch Leerzeichen - ein Kennwort mit Leerzeichen oder anderen # Sonderzeichen kam dadurch still verstuemmelt an, und die Anmeldung # schlug scheinbar grundlos fehl. "curl --config -" liest die Angaben von # der Standardeingabe und nimmt sie in Anfuehrungszeichen, vertraegt also # beliebige Zeichen. _curl_cfg() { # user pass -> Konfigurationszeile auf stdout local u="$1" p="$2" u="${u//\\/\\\\}"; u="${u//\"/\\\"}" p="${p//\\/\\\\}"; p="${p//\"/\\\"}" printf 'user = "%s:%s"\n' "$u" "$p" } curl_auth() { # user pass curl-argumente... local u="$1" p="$2"; shift 2 if [ -n "$u" ]; then _curl_cfg "$u" "$p" | curl --config - "$@" else curl "$@" fi } # HTTP-Status einer URL holen http_code() { # url [user] [pass] [extra-header] local url="$1" u="${2:-}" p="${3:-}" hdr="${4:-}" local -a a=(-sS -o /dev/null -w '%{http_code}' --max-time 30) if [ -n "$hdr" ]; then a+=(-H "$hdr"); fi local code code="$(curl_auth "$u" "$p" "${a[@]}" "$url" 2>/dev/null)" || code=000 printf '%s\n' "${code:-000}" } test_result() { # status text [detail] case "$1" in ok) printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$2" ;; fail) printf ' %s[FEHLER]%s %s\n' "$C_RED" "$C_RST" "$2" ;; warn) printf ' %s[ WARN ]%s %s\n' "$C_YEL" "$C_RST" "$2" ;; *) printf ' [ INFO ] %s\n' "$2" ;; esac [ -n "${3:-}" ] && printf ' %s\n' "$3" || true } # --- Registry pruefen ------------------------------------------------- # Der Name im Image-Bezeichner ist nicht immer der API-Host: # "docker.io" spricht man als "registry-1.docker.io" an. registry_api_host() { case "$1" in docker.io|index.docker.io) echo "registry-1.docker.io" ;; *) echo "$1" ;; esac } test_registry_login() { # reg repo user pass needs_login -> 0 = alles gut local reg="$1" repo="$2" user="$3" pass="$4" needs_login="${5:-}" local rc=0 anon="" local api; api="$(registry_api_host "$reg")" if [ -z "$user" ]; then anon=1; fi printf '\n%sRegistry %s%s\n' "$C_BLD" "$reg" "$C_RST" [ "$api" != "$reg" ] && printf ' API-Host: %s\n' "$api" if [ -n "$anon" ] && [ -n "$needs_login" ]; then test_result fail "keine Zugangsdaten hinterlegt" \ "Diese Edition ist nur mit Anmeldung erreichbar. CMK_REGISTRY_USER/-PASSWORD setzen oder --user/--password angeben." return 1 fi # 1) Erreichbarkeit local hdrf; hdrf="$(mktemp)" local code code="$(curl -sS -o /dev/null -D "$hdrf" -w '%{http_code}' --max-time 30 "https://$api/v2/" 2>/dev/null)" || code=000 code="${code:-000}" if [ "$code" = "000" ]; then test_result fail "nicht erreichbar" "Netzwerk/Proxy/DNS pruefen: https://$api/v2/" rm -f "$hdrf"; return 1 fi test_result ok "erreichbar (HTTP $code)" # 2) Authentifizierungsverfahren aus dem WWW-Authenticate-Kopf lesen local wa realm service wa="$(tr -d '\r' <"$hdrf" | grep -i '^www-authenticate:' | head -n1)" rm -f "$hdrf" [ -n "$anon" ] && test_result info "oeffentliches Repository - keine Anmeldung noetig" case "$wa" in *[Bb]earer*) realm="$(printf '%s' "$wa" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')" service="$(printf '%s' "$wa" | sed -n 's/.*service="\([^"]*\)".*/\1/p')" [ -n "$realm" ] || { test_result fail "Token-Dienst nicht ermittelbar" "$wa"; return 1; } local realm_host; realm_host="$(printf '%s' "$realm" | sed -E 's|^[a-z]+://([^/]+).*|\1|')" local url="$realm?service=$service&scope=repository:$repo:pull" local body tcode tu="" tp="" if [ -z "$anon" ]; then tu="$user"; tp="$pass"; fi body="$(curl_auth "$tu" "$tp" -sS --max-time 30 "$url" 2>/dev/null || true)" tcode="$(http_code "$url" "$tu" "$tp")" if [ "$tcode" = "401" ] || [ "$tcode" = "403" ]; then test_result fail "Anmeldung abgelehnt (HTTP $tcode)" \ "Benutzername/Kennwort des Checkmk-Kundenportals pruefen" return 1 fi if [ "$tcode" != "200" ]; then test_result fail "Token-Abruf fehlgeschlagen (HTTP $tcode)" return 1 fi [ -n "$anon" ] && test_result ok "anonymer Zugriff moeglich" \ || test_result ok "Anmeldung akzeptiert" # 3) Zugriff auf das konkrete Repository local token token="$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$token" ] || token="$(printf '%s' "$body" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" if [ -n "$token" ]; then local lcode lcode="$(http_code "https://$api/v2/$repo/tags/list" "" "" "Authorization: Bearer $token")" case "$lcode" in 200) test_result ok "Zugriff auf '$repo' vorhanden" ;; 401|403) test_result fail "kein Zugriff auf '$repo' (HTTP $lcode)" \ "Die Anmeldung stimmt, das Konto hat aber keine Berechtigung fuer dieses Repository (Subskription?)" rc=1 ;; 404) test_result warn "Repository '$repo' nicht gefunden (HTTP 404)" \ "Pfad pruefen - Anmeldung selbst ist in Ordnung" ;; *) test_result warn "Repository-Pruefung nicht eindeutig (HTTP $lcode)" ;; esac fi ;; *[Bb]asic*|"") local bcode; bcode="$(http_code "https://$api/v2/" "$user" "$pass")" case "$bcode" in 200) test_result ok "Anmeldung akzeptiert (Basic)" ;; 401|403) test_result fail "Anmeldung abgelehnt (HTTP $bcode)"; rc=1 ;; *) test_result warn "unklares Ergebnis (HTTP $bcode)"; rc=1 ;; esac ;; *) test_result warn "unbekanntes Anmeldeverfahren" "$wa"; rc=1 ;; esac return $rc } # Rueckfall ohne curl: Anmeldung ueber die Runtime, in eine Wegwerfdatei test_registry_login_runtime() { # reg user pass local reg="$1" user="$2" pass="$3" tmp rc=0 tmp="$(mktemp -d)" printf '\n%sRegistry %s (ueber %s)%s\n' "$C_BLD" "$reg" "$RUNTIME" "$C_RST" if [ "$RUNTIME" = podman ]; then if printf '%s' "$pass" | $RUNTIME login --username "$user" --password-stdin \ --authfile "$tmp/auth.json" "$reg" >/dev/null 2>&1; then test_result ok "Anmeldung akzeptiert" else test_result fail "Anmeldung abgelehnt"; rc=1 fi else if printf '%s' "$pass" | DOCKER_CONFIG="$tmp" $RUNTIME login --username "$user" \ --password-stdin "$reg" >/dev/null 2>&1; then test_result ok "Anmeldung akzeptiert" else test_result fail "Anmeldung abgelehnt"; rc=1 fi fi rm -rf "$tmp" return $rc } # --- Downloadportal pruefen ------------------------------------------- test_download_login() { # baseurl edition version user pass local base="$1" edition="$2" version="$3" user="$4" pass="$5" local host; host="$(printf '%s' "$base" | sed -E 's|^[a-z]+://([^/]+).*|\1|')" local rc=1 printf '\n%sDownloadportal %s%s\n' "$C_BLD" "$host" "$C_RST" if [ -z "$user" ]; then test_result warn "keine Zugangsdaten hinterlegt - uebersprungen" \ "CMK_DOWNLOAD_USER/-PASSWORD setzen oder --user/--password angeben" return 1 fi local -a urls=() if [ -n "${CMK_DOWNLOAD_URL:-}" ]; then urls=("$CMK_DOWNLOAD_URL") elif [ -n "$version" ]; then mapfile -t urls < <(download_url_list "$edition" "$version") else urls=("$base/") fi local u code seen401="" seen404="" for u in "${urls[@]}"; do code="$(http_code "$u" "$user" "$pass")" case "$code" in 200) test_result ok "Datei erreichbar (HTTP 200)" "$u"; rc=0; break ;; 401|403) seen401=1; test_result fail "Anmeldung abgelehnt (HTTP $code)" "$u" ;; 404) seen404=1 ;; 000) test_result fail "nicht erreichbar" "$u" ;; *) test_result warn "unerwartete Antwort (HTTP $code)" "$u" ;; esac done if [ "$rc" != 0 ] && [ -z "$seen401" ] && [ -n "$seen404" ]; then test_result ok "Anmeldung akzeptiert, aber kein Tarball unter diesem Namen (HTTP 404)" printf ' %s\n' "Version pruefen (z.B. 2.3.0p23, kein '-latest') oder" printf ' %s\n' "CMK_DOWNLOAD_URL mit der vollstaendigen URL setzen." rc=0 fi return $rc } image_cmd_test() { local edition="${CMK_EDITION:-raw}" version="${CMK_VERSION:-}" local ruser="${CMK_REGISTRY_USER:-}" rpass="${CMK_REGISTRY_PASSWORD:-}" local duser="${CMK_DOWNLOAD_USER:-}" dpass="${CMK_DOWNLOAD_PASSWORD:-}" local reg="${CMK_REGISTRY:-registry.checkmk.com}" local only="" while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; --registry) reg="$2"; shift 2 ;; --user) ruser="$2"; duser="$2"; shift 2 ;; --password) rpass="$2"; dpass="$2"; shift 2 ;; --only) only="$2"; shift 2 ;; *) die "Unbekannte Option fuer 'image test': $1" ;; esac done edition_known "$edition" || die "Unbekannte Edition '$edition'." printf '\n%sPruefe Zugangsdaten%s\n' "$C_BLD" "$C_RST" printf ' Edition : %s\n' "$edition" printf ' Version : %s\n' "${version:-}" local rc_reg=1 rc_dl=1 ran_reg="" ran_dl="" if [ -z "$only" ] || [ "$only" = registry ]; then ran_reg=1 local full repo CMK_REGISTRY="$reg" full="$(edition_repo "$edition")" repo="${full#*/}" local rhost="${full%%/*}" local needs="" edition_needs_login "$edition" && needs=1 if command -v curl >/dev/null 2>&1; then test_registry_login "$rhost" "$repo" "$ruser" "$rpass" "$needs" && rc_reg=0 elif [ -n "$ruser" ]; then test_registry_login_runtime "$rhost" "$ruser" "$rpass" && rc_reg=0 else warn "Weder curl noch Zugangsdaten vorhanden - Registry uebersprungen." fi fi if [ -z "$only" ] || [ "$only" = download ]; then ran_dl=1 if command -v curl >/dev/null 2>&1; then test_download_login "${CMK_DOWNLOAD_BASEURL:-https://download.checkmk.com/checkmk}" \ "$edition" "$version" "$duser" "$dpass" && rc_dl=0 else warn "curl fehlt - Downloadportal kann nicht geprueft werden." fi fi [ -n "$ran_reg$ran_dl" ] || die "Unbekannter Wert fuer --only: '$only' (registry|download)" echo local good="" [ -n "$ran_reg" ] && [ "$rc_reg" = 0 ] && good=1 [ -n "$ran_dl" ] && [ "$rc_dl" = 0 ] && good=1 if [ -n "$good" ]; then ok "Zugangsweg nutzbar." [ -n "$ran_reg" ] && [ "$rc_reg" = 0 ] && \ log "Weiter mit: $SELF image pull --edition $edition --version ${version:-}" [ -n "$ran_dl" ] && [ "$rc_dl" = 0 ] && [ "${rc_reg}" != 0 ] && \ log "Weiter mit: $SELF image download --edition $edition --version ${version:-}" echo return 0 fi err "Kein geprüfter Bezugsweg ist nutzbar - bitte Zugangsdaten pruefen." echo return 1 } # Warum laesst sich eine Image-Referenz nicht holen? # # Die Fehlermeldungen der Runtime sind eindeutig, aber unerklaert: # "manifest unknown" -> Repository ist da, das TAG nicht # "repository name unknown" -> Repository gibt es nicht # "unauthorized" -> Anmeldung oder Berechtigung # Diese Funktion fragt die Registry direkt und sagt, welcher Fall vorliegt. image_diagnose_ref() { # ref local ref="$1" command -v curl >/dev/null 2>&1 || return 0 local tag regpart reg repo api tag="${ref##*:}"; regpart="${ref%:*}" case "$regpart" in */*) reg="${regpart%%/*}"; repo="${regpart#*/}" ;; *) reg="docker.io"; repo="$regpart" ;; esac [ "$tag" = "$ref" ] && tag="" api="$(registry_api_host "$reg")" local u="" p="" case "$reg" in docker.io|index.docker.io|registry-1.docker.io) : ;; *) u="${CMK_REGISTRY_USER:-}"; p="${CMK_REGISTRY_PASSWORD:-}" ;; esac printf '\n%sWoran liegt es?%s\n' "$C_BLD" "$C_RST" printf ' Registry : %s\n' "$reg" printf ' Repository : %s\n' "$repo" printf ' Tag : %s\n\n' "${tag:-}" local line code line="$(registry_repo_status "$api" "$repo" "$u" "$p")" code="${line%% *}" case "$code" in 401|403) test_result fail "Anmeldung oder Berechtigung (HTTP $code)" printf ' Pruefen: %s image test --only registry\n' "$SELF" return 1 ;; 404) test_result fail "Dieses Repository gibt es dort nicht (HTTP 404)" printf ' Welche es gibt: %s image repos --edition %s\n' "$SELF" "${CMK_EDITION:-enterprise}" return 1 ;; 200) : ;; *) test_result warn "Registry antwortet unerwartet (HTTP $code)"; return 1 ;; esac test_result ok "Repository vorhanden und zugaenglich" local -a tags=() mapfile -t tags < <(registry_tags "$api" "$repo" "$u" "$p" | sed '/^$/d') if [ ${#tags[@]} -eq 0 ]; then test_result warn "Es liess sich keine Tag-Liste lesen." return 1 fi if ( set +o pipefail; printf '%s\n' "${tags[@]}" | grep -qxF "$tag" ); then test_result ok "Das Tag '$tag' ist vorhanden - der Fehler hat eine andere Ursache." return 0 fi test_result fail "Das Tag '$tag' gibt es in diesem Repository NICHT." printf ' Genau das meint die Meldung "manifest unknown".\n\n' # Naheliegende Tags zeigen: gleicher Zweig, sonst die neuesten local branch="${tag%%p*}" local -a near=() mapfile -t near < <(printf '%s\n' "${tags[@]}" | grep -F "$branch" | sort -rV | head -n 12) if [ ${#near[@]} -gt 0 ]; then printf ' %sVorhanden im Zweig %s%s\n' "$C_BLD" "$branch" "$C_RST" else mapfile -t near < <(printf '%s\n' "${tags[@]}" | sort -rV | head -n 12) printf ' %sVorhanden (neueste)%s\n' "$C_BLD" "$C_RST" fi printf ' %s\n' "${near[@]}" # Registries halten meist nur die juengsten Patchstaende vor. Aeltere # Versionen liegen weiterhin im Downloadportal - und genau die braucht # man bei einer Migration, weil "omd restore" die exakte Version # verlangt. Deshalb hier nachsehen, statt den Benutzer suchen zu lassen. local tarball="" if [ -n "$tag" ]; then tarball="$(archive_docker_file "${CMK_EDITION:-enterprise}" "$tag" 2>/dev/null || true)" fi if [ -n "$tarball" ]; then cat < --remember\n' "$SELF" "$reg" "$repo" printf ' %s up\n' "$SELF" fi printf '\n Alle Tags: %s image versions --source registry --edition %s --all\n\n' \ "$SELF" "${CMK_EDITION:-enterprise}" return 1 } # --------------------------------------------------------------------- # Image holen - ueber den eingestellten Bezugsweg # # CMK_IMAGE_SOURCE bestimmt, woher: # registry nur aus der Registry # download nur als Tarball vom Downloadportal # auto erst Registry, dann Tarball (Vorgabe) # # "auto" ist die sinnvolle Vorgabe, weil Registries nur die juengsten # Patchstaende vorhalten. Eine aeltere, fuer eine Migration noetige # Version liegt dann nur noch im Downloadportal. # --------------------------------------------------------------------- image_source_default() { case "$(printf '%s' "${CMK_IMAGE_SOURCE:-auto}" | tr 'A-Z' 'a-z')" in registry|reg|r) printf 'registry\n' ;; download|dl|tarball|d) printf 'download\n' ;; *) printf 'auto\n' ;; esac } image_cmd_get() { local edition="${CMK_EDITION:-}" version="${CMK_VERSION:-}" src="" local -a extra=() while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --version) version="$2"; shift 2 ;; --source) src="$2"; shift 2 ;; --user) extra+=(--user "$2"); shift 2 ;; --password) extra+=(--password "$2"); shift 2 ;; *) die "Unbekannte Option fuer 'image get': $1" ;; esac done [ -n "$src" ] || src="$(image_source_default)" case "$src" in registry|download|auto) : ;; *) die "--source: registry | download | auto" ;; esac [ -n "$edition" ] || die "Keine Edition eingestellt (CMK_EDITION oder --edition)." [ -n "$version" ] || die "Keine Version eingestellt (CMK_VERSION oder --version)." case "$src" in registry) image_cmd_pull --edition "$edition" --version "$version" --remember ${extra[@]+"${extra[@]}"} ;; download) image_cmd_download --edition "$edition" --version "$version" --remember ${extra[@]+"${extra[@]}"} ;; auto) log "Bezugsweg 'auto': zuerst die Registry ..." if IMAGE_QUIET_FAIL=1 image_cmd_pull --edition "$edition" --version "$version" \ --remember ${extra[@]+"${extra[@]}"}; then return 0 fi warn "Aus der Registry nicht zu bekommen - versuche das Downloadportal." if image_cmd_download --edition "$edition" --version "$version" \ --remember ${extra[@]+"${extra[@]}"}; then return 0 fi err "Weder Registry noch Downloadportal haben geliefert." image_diagnose_ref "$(edition_repo "$edition"):$version" || true return 1 ;; esac } # --------------------------------------------------------------------- # image versions - verfuegbare Versionen auflisten # # Registry : ueber den Docker-Registry-v2-Ablauf /v2//tags/list # Portal : ueber das Verzeichnis-Listing von download.checkmk.com # (nur mit Zugangsdaten lesbar) # --------------------------------------------------------------------- # Token fuer eine Registry besorgen ("" = anonym versuchen) registry_get_token() { # api repo user pass [scope] -> Token auf stdout local api="$1" repo="$2" user="$3" pass="$4" scope="${5:-}" [ -n "$scope" ] || scope="repository:$repo:pull" local hdrf; hdrf="$(mktemp)" curl -sS -o /dev/null -D "$hdrf" --max-time 30 "https://$api/v2/" >/dev/null 2>&1 || true local wa realm service wa="$(tr -d '\r' <"$hdrf" | grep -i '^www-authenticate:' | head -n1)" rm -f "$hdrf" case "$wa" in *[Bb]earer*) : ;; *) return 0 ;; # Basic oder offen - kein Token noetig esac realm="$(printf '%s' "$wa" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')" service="$(printf '%s' "$wa" | sed -n 's/.*service="\([^"]*\)".*/\1/p')" [ -n "$realm" ] || return 0 local realm_host; realm_host="$(printf '%s' "$realm" | sed -E 's|^[a-z]+://([^/]+).*|\1|')" local url="$realm?service=$service&scope=$scope" local body body="$(curl_auth "$user" "$pass" -sS --max-time 30 "$url" 2>/dev/null || true)" local t t="$(printf '%s' "$body" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$t" ] || t="$(printf '%s' "$body" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" printf '%s' "$t" } registry_tags() { # api repo user pass -> Tags, eine je Zeile local api="$1" repo="$2" user="$3" pass="$4" local token; token="$(registry_get_token "$api" "$repo" "$user" "$pass")" local -a a=(-sS --max-time 45) local tu="" tp="" if [ -n "$token" ]; then a+=(-H "Authorization: Bearer $token"); else tu="$user"; tp="$pass"; fi local body body="$(curl_auth "$tu" "$tp" "${a[@]}" "https://$api/v2/$repo/tags/list" 2>/dev/null || true)" printf '%s' "$body" \ | tr ',' '\n' \ | sed -n 's/.*"tags"[[:space:]]*:[[:space:]]*\[[[:space:]]*"\([^"]*\)".*/\1/p;s/^[[:space:]]*"\([^"]*\)".*/\1/p' \ | sed 's/[]["]//g' | sed '/^$/d' } download_versions() { # user pass -> Versionsverzeichnisse local user="$1" pass="$2" local base="${CMK_DOWNLOAD_BASEURL:-https://download.checkmk.com/checkmk}" local host; host="$(printf '%s' "$base" | sed -E 's|^[a-z]+://([^/]+).*|\1|')" local -a a=(-sS --max-time 45 -L) local body; body="$(curl_auth "$user" "$pass" "${a[@]}" "$base/" 2>/dev/null || true)" printf '%s' "$body" \ | grep -oE 'href="[0-9]+\.[0-9]+\.[0-9]+[a-z0-9.]*/?"' \ | sed -E 's|href="([^"/]+)/?"|\1|' | sort -u } # --------------------------------------------------------------------- # Welches Repository heisst in der Registry wie? # # Der Pfad ist nicht zu erraten - "Login Succeeded" heisst noch lange # nicht, dass enterprise/check-mk-enterprise existiert. Deshalb erst den # Katalog fragen und, falls der gesperrt ist, die ueblichen Pfade # durchprobieren. HTTP 404 = gibt es nicht, 401/403 = keine Berechtigung, # 200 = nutzbar. # --------------------------------------------------------------------- registry_catalog() { # api user pass -> Repository-Namen local api="$1" user="$2" pass="$3" local token; token="$(registry_get_token "$api" "" "$user" "$pass" "registry:catalog:*")" local -a a=(-sS --max-time 45) local tu="" tp="" if [ -n "$token" ]; then a+=(-H "Authorization: Bearer $token"); else tu="$user"; tp="$pass"; fi curl_auth "$tu" "$tp" "${a[@]}" "https://$api/v2/_catalog?n=1000" 2>/dev/null \ | tr ',' '\n' | grep -oE '"[a-z0-9][a-z0-9._/-]*"' | tr -d '"' \ | grep -v '^repositories$' | sed '/^$/d' | sort -u } registry_repo_candidates() { # edition -> moegliche Repository-Pfade local edition="$1" a for a in $(edition_aliases "$edition"); do printf '%s/check-mk-%s\n' "$a" "$a" printf 'check-mk-%s\n' "$a" printf 'checkmk/check-mk-%s\n' "$a" printf 'enterprise/check-mk-%s\n' "$a" done } # Status eines Repositories ermitteln: 200 / 401 / 403 / 404 / 000 registry_repo_status() { # api repo user pass -> "code anzahl_tags" local api="$1" repo="$2" user="$3" pass="$4" local token; token="$(registry_get_token "$api" "$repo" "$user" "$pass")" local -a a=(-sS -o /tmp/.cmkrepo.$$ -w '%{http_code}' --max-time 30) local tu="" tp="" if [ -n "$token" ]; then a+=(-H "Authorization: Bearer $token"); else tu="$user"; tp="$pass"; fi local code code="$(curl_auth "$tu" "$tp" "${a[@]}" "https://$api/v2/$repo/tags/list" 2>/dev/null)" || code=000 local n=0 if [ "${code:-000}" = 200 ]; then n="$(tr ',' '\n' <"/tmp/.cmkrepo.$$" 2>/dev/null | grep -c '"' || echo 0)"; fi rm -f "/tmp/.cmkrepo.$$" printf '%s %s\n' "${code:-000}" "$n" } image_cmd_repos() { local edition="${CMK_EDITION:-enterprise}" reg="${CMK_REGISTRY:-registry.checkmk.com}" local user="${CMK_REGISTRY_USER:-}" pass="${CMK_REGISTRY_PASSWORD:-}" while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --registry) reg="$2"; shift 2 ;; --user) user="$2"; shift 2 ;; --password) pass="$2"; shift 2 ;; *) die "Unbekannte Option fuer 'image repos': $1" ;; esac done command -v curl >/dev/null 2>&1 || die "Fuer 'image repos' wird curl benoetigt." local api; api="$(registry_api_host "$reg")" printf '\n%sRegistry %s%s\n' "$C_BLD" "$reg" "$C_RST" [ -n "$user" ] || warn "Keine Zugangsdaten hinterlegt - ohne Anmeldung ist hier wenig zu sehen." printf '\n%sKatalog (/v2/_catalog)%s\n' "$C_BLD" "$C_RST" local -a cat=() mapfile -t cat < <(registry_catalog "$api" "$user" "$pass" | grep -iE 'check|mk' | head -50) if [ ${#cat[@]} -gt 0 ]; then printf ' %s\n' "${cat[@]}" else printf ' (nicht abrufbar - viele Registries sperren den Katalog)\n' fi printf '\n%sUebliche Pfade fuer "%s" durchprobiert%s\n' "$C_BLD" "$edition" "$C_RST" printf ' %-6s %-8s %s\n' "STATUS" "TAGS" "PFAD" printf ' %s200 = nutzbar, 404 = gibt es nicht, 401/403 = kein Zugriff%s\n' "$C_BLU" "$C_RST" local cand code n line ok="" while IFS= read -r cand; do [ -n "$cand" ] || continue line="$(registry_repo_status "$api" "$cand" "$user" "$pass")" code="${line%% *}"; n="${line##* }" case "$code" in 200) printf ' %s%-6s%s %-8s %s\n' "$C_GRN" "$code" "$C_RST" "$n" "$cand" if [ -z "$ok" ]; then ok="$cand"; fi ;; 401|403) printf ' %s%-6s%s %-8s %s (kein Zugriff - oder gibt es nicht;\n%38sviele Registries verbergen das)\n' \ "$C_YEL" "$code" "$C_RST" "-" "$cand" "" ;; 404) printf ' %-6s %-8s %s (gibt es nicht)\n' "$code" "-" "$cand" ;; *) printf ' %-6s %-8s %s\n' "${code:-000}" "-" "$cand" ;; esac done < <(registry_repo_candidates "$edition" | awk '!seen[$0]++') echo if [ -n "$ok" ]; then ok "Nutzbar: $reg/$ok" cat < EOF else err "Unter '$reg' wurde kein nutzbares Repository fuer '$edition' gefunden." cat < * Der Pfad heisst anders. Steht er im Kundenportal, laesst er sich direkt setzen: $SELF config set CMK_REGISTRY $SELF image use /: EOF fi echo } image_cmd_versions() { local edition="${CMK_EDITION:-raw}" source="all" filter="" limit=25 local ruser="${CMK_REGISTRY_USER:-}" rpass="${CMK_REGISTRY_PASSWORD:-}" local duser="${CMK_DOWNLOAD_USER:-}" dpass="${CMK_DOWNLOAD_PASSWORD:-}" local reg="${CMK_REGISTRY:-registry.checkmk.com}" raw="" while [ $# -gt 0 ]; do case "$1" in --edition) edition="$2"; shift 2 ;; --source) source="$2"; shift 2 ;; --filter) filter="$2"; shift 2 ;; --limit) limit="$2"; shift 2 ;; --all) limit=100000; shift ;; --registry) reg="$2"; shift 2 ;; --user) ruser="$2"; duser="$2"; shift 2 ;; --password) rpass="$2"; dpass="$2"; shift 2 ;; --bare) raw=1; shift ;; *) die "Unbekannte Option fuer 'image versions': $1" ;; esac done edition_known "$edition" || die "Unbekannte Edition '$edition'. Moeglich: raw community free cloud managed pro ultimate ultimatemt enterprise" case "$source" in registry|download|web|both|all) : ;; *) die "--source: registry | download | web | all" ;; esac if [ "$source" = both ]; then source=all; fi case "$limit" in ''|*[!0-9]*) die "--limit braucht eine Zahl." ;; esac command -v curl >/dev/null 2>&1 || die "Fuer 'image versions' wird curl benoetigt." CMK_REGISTRY="$reg" local full repo api full="$(edition_repo "$edition")" repo="${full#*/}" api="$(registry_api_host "${full%%/*}")" local found=0 if [ "$source" = web ] || [ "$source" = all ]; then [ -n "$raw" ] || printf '\n%sVersionsarchiv %s%s\n' "$C_BLD" \ "${CMK_ARCHIVE_URL:-https://checkmk.com/download/archive}" "$C_RST" [ -n "$raw" ] || printf ' %s(oeffentlich, keine Zugangsdaten noetig)%s\n' "$C_BLU" "$C_RST" local -a wv=() mapfile -t wv < <(archive_versions 2>/dev/null \ | { [ -n "$filter" ] && grep -F "$filter" || cat; } \ | sort -rV | head -n "$limit") if [ ${#wv[@]} -eq 0 ]; then [ -n "$raw" ] || test_result warn "Archiv nicht lesbar" "Netzzugang zu checkmk.com pruefen" else found=1 printf '%s\n' "${wv[@]}" | { [ -n "$raw" ] && cat || sed 's/^/ /'; } fi fi if [ "$source" = registry ] || [ "$source" = all ]; then [ -n "$raw" ] || printf '\n%sRegistry %s%s\n' "$C_BLD" "$full" "$C_RST" local -a tags=() mapfile -t tags < <(registry_tags "$api" "$repo" "$ruser" "$rpass" \ | { [ -n "$filter" ] && grep -F "$filter" || cat; } \ | sort -rV | head -n "$limit") if [ ${#tags[@]} -eq 0 ]; then if [ -z "$raw" ]; then if edition_needs_login "$edition" && [ -z "$ruser" ]; then test_result warn "keine Zugangsdaten - Registry nicht lesbar" \ "CMK_REGISTRY_USER/-PASSWORD setzen oder --user/--password angeben" else test_result warn "keine Tags erhalten" "Zugangsdaten und Repository pruefen" fi fi else found=1 printf '%s\n' "${tags[@]}" | { [ -n "$raw" ] && cat || sed 's/^/ /'; } fi fi if [ "$source" = download ] || [ "$source" = all ]; then [ -n "$raw" ] || printf '\n%sDownloadportal %s%s\n' "$C_BLD" \ "${CMK_DOWNLOAD_BASEURL:-https://download.checkmk.com/checkmk}" "$C_RST" local -a vers=() mapfile -t vers < <(download_versions "$duser" "$dpass" \ | { [ -n "$filter" ] && grep -F "$filter" || cat; } \ | sort -rV | head -n "$limit") if [ ${#vers[@]} -eq 0 ]; then if [ -z "$raw" ]; then if [ -z "$duser" ]; then test_result warn "keine Zugangsdaten - das Verzeichnis ist geschuetzt (HTTP 401)" \ "CMK_DOWNLOAD_USER/-PASSWORD setzen oder --user/--password angeben" else test_result warn "keine Versionen gelesen" \ "Zugangsdaten pruefen: $SELF image test --only download" fi fi else found=1 printf '%s\n' "${vers[@]}" | { [ -n "$raw" ] && cat || sed 's/^/ /'; } fi fi [ -n "$raw" ] && return 0 echo if [ "$found" = 1 ]; then cat < als Tarball : $SELF image download --edition $edition --version nur eintragen : $SELF config set CMK_VERSION Nur die nackte Liste (zum Weiterverarbeiten): $SELF image versions --bare EOF else err "Es konnte keine Versionsliste gelesen werden." log "Zugangsdaten pruefen mit: $SELF image test" fi echo [ "$found" = 1 ] } cmd_image() { local sub="${1:-status}"; [ $# -gt 0 ] && shift || true case "$sub" in status) image_cmd_status ;; list|ls) image_cmd_list ;; pull) image_cmd_pull "$@" ;; download) image_cmd_download "$@" ;; load) image_cmd_load "$@" ;; use|set) image_cmd_use "$@" ;; test|check) image_cmd_test "$@" ;; versions|tags) image_cmd_versions "$@" ;; repos|repo) image_cmd_repos "$@" ;; why|diagnose) image_diagnose_ref "${1:-${CMK_IMAGE:-}}" ;; get|fetch) image_cmd_get "$@" ;; *) die "Unbekanntes Unterkommando 'image $sub'. Moeglich: status | list | versions | repos | why | test | get | pull | download | load | use" ;; esac } # ===================================================================== # Stack-Kommandos # ===================================================================== # Welche compose-Dienste sollen laufen? Der Proxy Manager ist abschaltbar, # damit ein bereits vorhandener NGINX auf einem anderen System genutzt # werden kann. Abgeschaltet wird sein Container gar nicht erst angelegt. compose_services() { if npm_enabled; then printf '%s\n' checkmk nginx-proxy-manager else printf '%s\n' checkmk fi } cmd_up() { [ -f "$PROJECT_DIR/.env" ] || die "Keine .env vorhanden. Bitte: cp .env.example .env && \$EDITOR .env" local -a svc=(); mapfile -t svc < <(compose_services) if npm_enabled; then log "Starte Stack (${RUNTIME}) ..." else log "Starte Stack (${RUNTIME}) - ohne NGINX Proxy Manager (NPM_ENABLED=off) ..." fi # Fehlt das Image lokal, es selbst holen - ueber den eingestellten # Bezugsweg. Sonst versucht compose einen nackten Pull, der bei einer # nur im Downloadportal vorhandenen Version scheitern muss. if [ -z "${UP_NO_FETCH:-}" ] && [ -n "${CMK_IMAGE:-}" ] \ && ! image_exists_local "$CMK_IMAGE" \ && [ -n "${CMK_EDITION:-}" ] && [ -n "${CMK_VERSION:-}" ]; then log "Das Image ist lokal nicht vorhanden - hole es ($(image_source_default)) ..." if ! image_cmd_get; then err "Das Image konnte nicht beschafft werden - der Stack wird nicht gestartet." return 1 fi load_env "${ENV_FILE:-$PROJECT_DIR/.env}" fi if ! compose up -d "${svc[@]}"; then echo err "Der Stack konnte nicht gestartet werden." # Haeufigste Ursache: das Image laesst sich nicht holen. if [ -n "${CMK_IMAGE:-}" ] && ! image_exists_local "$CMK_IMAGE"; then image_diagnose_ref "$CMK_IMAGE" || true fi return 1 fi npm_enabled || npm_teardown_container ok "Stack gestartet." cmd_status } cmd_down() { log "Stoppe Stack ..."; compose down; ok "Stack gestoppt."; } cmd_restart() { compose down || true local -a svc=(); mapfile -t svc < <(compose_services) compose up -d "${svc[@]}" npm_enabled || npm_teardown_container ok "Stack neu gestartet." } cmd_pull() { local -a svc=(); mapfile -t svc < <(compose_services) compose pull "${svc[@]}" ok "Images aktualisiert. Danach: $SELF up" } cmd_logs() { compose logs "$@"; } cmd_shell() { require_cmk_running; $RUNTIME exec -it "$CMK_CONTAINER_NAME" bash; } cmd_omd() { require_cmk_running; $RUNTIME exec -it "$CMK_CONTAINER_NAME" omd "$@"; } cmd_status() { local cmsg printf '\n%sImage%s\n' "$C_BLD" "$C_RST" printf ' %-24s %s\n' "${CMK_IMAGE:-}" \ "$(image_exists_local "${CMK_IMAGE:-}" && echo "lokal vorhanden" || echo "${C_YEL}nicht lokal vorhanden${C_RST}")" if ! cmsg="$(image_consistency_check)"; then printf ' %sAchtung%s: CMK_IMAGE passt nicht zu CMK_EDITION/CMK_VERSION.\n' "$C_YEL" "$C_RST" printf ' Einzelheiten: %s image status\n' "$SELF" fi printf '\n%sContainer%s\n' "$C_BLD" "$C_RST" local c for c in "$CMK_CONTAINER_NAME" "$NPM_CONTAINER_NAME"; do if [ "$c" = "$NPM_CONTAINER_NAME" ] && ! npm_enabled; then printf ' %-24s %s\n' "$c" "${C_YEL}abgeschaltet (NPM_ENABLED=off)${C_RST}" continue fi if container_exists "$c"; then printf ' %-24s %s\n' "$c" \ "$(container_running "$c" && echo "${C_GRN}laeuft${C_RST}" || echo "${C_RED}gestoppt${C_RST}")" else printf ' %-24s %s\n' "$c" "${C_YEL}nicht vorhanden${C_RST}" fi done if container_running "$CMK_CONTAINER_NAME"; then printf '\n%sSites im Container%s\n' "$C_BLD" "$C_RST" printf ' %-20s %-18s %s\n' "SITE" "VERSION" "STATUS" local s while IFS= read -r s; do [ -n "$s" ] || continue printf ' %-20s %-18s %s\n' "$s" "$(cmk_site_version "$s")" "$(cmk_site_state_text "$s")" done < <(cmk_sites) printf '\n Standard-Site laut .env : %s\n' "$CMK_SITE_ID" fi printf '\n%sZugang%s\n' "$C_BLD" "$C_RST" local ip; ip="$(primary_ip)" printf ' Checkmk (direkt) : http://%s:5000/%s/\n' "$ip" "$CMK_SITE_ID" printf ' Agent-Receiver : %s:8000\n' "$ip" if npm_enabled; then printf ' Checkmk (ueber Proxy) : https:///%s/\n' "$CMK_SITE_ID" printf ' NGINX Proxy Manager : http://%s:%s/\n' "$ip" "${NPM_ADMIN_PORT:-81}" if [ -n "${NPM_ADMIN_EMAIL:-}" ]; then printf ' Anmeldung: %s\n\n' "$NPM_ADMIN_EMAIL" else printf ' %sKein Konto hinterlegt%s - NPM_ADMIN_EMAIL/-PASSWORD setzen\n\n' "$C_YEL" "$C_RST" fi else printf ' NGINX Proxy Manager : abgeschaltet\n' printf ' Externer Proxy weiterleiten auf http://%s:5000\n\n' "$ip" fi } # ===================================================================== # Site-Kommandos # ===================================================================== cmd_sites() { require_cmk_running printf '%-20s %-18s %s\n' "SITE" "VERSION" "STATUS" local s while IFS= read -r s; do [ -n "$s" ] || continue printf '%-20s %-18s %s\n' "$s" "$(cmk_site_version "$s")" "$(cmk_site_state_text "$s")" done < <(cmk_sites) } cmd_site_start() { require_cmk_running [ $# -ge 1 ] || die "Site-Name fehlt." ct omd start "$1" && ok "Site '$1' gestartet." } cmd_site_stop() { require_cmk_running [ $# -ge 1 ] || die "Site-Name fehlt." ct omd stop "$1" && ok "Site '$1' gestoppt." } # Wird vom systemd-Unit nach dem Start aufgerufen: alle Sites hochfahren. # (Der Container-Entrypoint startet nur CMK_SITE_ID.) cmd_start_all_sites() { local tries=0 while ! container_running "$CMK_CONTAINER_NAME"; do tries=$((tries+1)); [ "$tries" -gt 60 ] && die "Container '$CMK_CONTAINER_NAME' startet nicht." sleep 2 done # kurz warten, bis der Entrypoint durch ist sleep 5 local s while IFS= read -r s; do [ -n "$s" ] || continue if [ "$(cmk_site_state "$s")" != "0" ]; then log "Starte Site '$s' ..." ct omd start "$s" || warn "Site '$s' konnte nicht gestartet werden." else debug "Site '$s' laeuft bereits." fi done < <(cmk_sites) ok "Alle Sites geprueft/gestartet." } # ===================================================================== # Backup # ===================================================================== cmd_backup() { local -a sites=() opts=() local all="" dir="" label="" stop_site="" while [ $# -gt 0 ]; do case "$1" in --site) sites+=("$2"); shift 2 ;; --all) all=1; shift ;; --dir) dir="$2"; shift 2 ;; --label) label="$2"; shift 2 ;; --no-rrds) opts+=(--no-rrds); shift ;; --no-logs) opts+=(--no-logs); shift ;; --stop) stop_site=1; shift ;; *) die "Unbekannte Option fuer backup: $1" ;; esac done require_cmk_running dir="${dir:-$BACKUP_DIR}" mkdir -p "$dir" if [ -n "$all" ]; then mapfile -t sites < <(cmk_sites) fi [ ${#sites[@]} -gt 0 ] || die "Keine Site angegeben (--site SITE oder --all)." local s for s in "${sites[@]}"; do cmk_site_exists "$s" || die "Site '$s' existiert im Container nicht." backup_one_site "$s" "$dir" "$label" "$stop_site" "${opts[@]+"${opts[@]}"}" done } backup_one_site() { local site="$1" dir="$2" label="$3" stop_site="$4"; shift 4 local -a opts=("$@") local ts version outfile tmp restarted="" ts="$(date +%Y%m%d-%H%M%S)" version="$(cmk_site_version "$site")" outfile="$dir/cmkbackup_${site}_${version:-unknown}${label:+_$label}_${ts}.tar.gz" if [ -n "$stop_site" ] && [ "$(cmk_site_state "$site")" = "0" ]; then log "Stoppe Site '$site' fuer ein konsistentes Backup ..." ct omd stop "$site" >/dev/null restarted=1 fi tmp="$(mktemp -d)" mkdir -p "$tmp/$BUNDLE_DIRNAME" log "Erzeuge omd-Backup der Site '$site' ${opts[*]+(${opts[*]})} ..." if ! $RUNTIME exec "$CMK_CONTAINER_NAME" omd backup ${opts[@]+"${opts[@]}"} "$site" - \ >"$tmp/$BUNDLE_DIRNAME/site.tar.gz" 2>"$tmp/err.log" \ || [ ! -s "$tmp/$BUNDLE_DIRNAME/site.tar.gz" ]; then warn "Streaming-Backup nicht moeglich - weiche auf Zwischendatei im Container aus." debug "$(cat "$tmp/err.log" 2>/dev/null)" ct sh -c "rm -f /var/tmp/cmkbackup.tar.gz" ct omd backup ${opts[@]+"${opts[@]}"} "$site" /var/tmp/cmkbackup.tar.gz \ || { rm -rf "$tmp"; die "omd backup fehlgeschlagen."; } $RUNTIME cp "$CMK_CONTAINER_NAME:/var/tmp/cmkbackup.tar.gz" "$tmp/$BUNDLE_DIRNAME/site.tar.gz" ct sh -c "rm -f /var/tmp/cmkbackup.tar.gz" fi [ -n "$restarted" ] && { log "Starte Site '$site' wieder ..."; ct omd start "$site" >/dev/null; } local sum size sum="$(sha256sum "$tmp/$BUNDLE_DIRNAME/site.tar.gz" | awk '{print $1}')" size="$(file_size "$tmp/$BUNDLE_DIRNAME/site.tar.gz")" printf '%s site.tar.gz\n' "$sum" >"$tmp/$BUNDLE_DIRNAME/site.tar.gz.sha256" cat >"$tmp/$BUNDLE_DIRNAME/MANIFEST" </dev/null || hostname) SOURCE_IP=$(primary_ip) SOURCE_OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || echo unbekannt) CREATED=$(date -Is) CREATED_BY=cmk-manage.sh ARCHIVE=site.tar.gz ARCHIVE_SHA256=$sum ARCHIVE_SIZE=$size OMD_BACKUP_OPTS=${opts[*]-} LABEL=$label AGENT_RECEIVER_PORT=8000 EOF cat >"$tmp/$BUNDLE_DIRNAME/INFO.txt" < --reuse EOF log "Verpacke Bundle ..." tar -czf "$outfile" -C "$tmp" "$BUNDLE_DIRNAME" rm -rf "$tmp" ok "Backup erstellt: $outfile ($(human_size "$(file_size "$outfile")"))" LAST_BACKUP_FILE="$outfile" } # ===================================================================== # Backup-Jobs (geplante Backups) # # Manuelle Backups nehmen immer BACKUP_DIR aus der .env. Geplante Jobs # bringen ihr eigenes Zielverzeichnis mit - so kann z.B. taeglich auf # eine NAS-Freigabe und woechentlich woanders hin gesichert werden. # # Ablage: backup-jobs.conf im Projektverzeichnis, eine Zeile je Job: # name|sites|cron|verzeichnis|optionen|aufbewahren|aktiv # Ausgefuehrt werden sie ueber /etc/cron.d/checkmk-backup, das aus # dieser Datei erzeugt wird ("job auto on"). # ===================================================================== FW_STATE_FILE_NAME="firewall-opened.conf" JOBS_FILE_NAME="backup-jobs.conf" CRON_FILE="/etc/cron.d/checkmk-backup" jobs_file() { printf '%s/%s\n' "$PROJECT_DIR" "$JOBS_FILE_NAME"; } jobs_init() { local f; f="$(jobs_file)" [ -f "$f" ] && return 0 cat >"$f" <<'EOF' # Backup-Jobs fuer cmk-manage.sh - eine Zeile je Job: # name|sites|cron|verzeichnis|optionen|aufbewahren|aktiv # # name nur Buchstaben, Ziffern, _ und - # sites ALL oder Site-Namen durch Komma getrennt # cron fuenf Cron-Felder, z.B. "15 2 * * *" # verzeichnis Zielverzeichnis (absolut oder relativ zum Projekt) # optionen leer oder --no-rrds / --no-logs / --stop (mit Leerzeichen) # aufbewahren Anzahl der Backups, die im Zielverzeichnis bleiben (0 = alle) # aktiv on oder off # # Bearbeiten moeglichst ueber: cmk-manage.sh job ... bzw. das Menue. EOF ok "Neue Jobdatei angelegt: $f" } jobs_read() { # nur Datenzeilen local f; f="$(jobs_file)" [ -f "$f" ] || return 0 grep -vE '^[[:space:]]*(#|$)' "$f" || true } job_line() { jobs_read | awk -F'|' -v n="$1" '$1==n {print; exit}'; } job_exists() { [ -n "$(job_line "$1")" ]; } job_f() { printf '%s' "$1" | cut -d'|' -f"$2"; } job_dir_abs() { # Verzeichnis eines Jobs absolut local d="$1" case "$d" in /*) printf '%s\n' "$d" ;; *) printf '%s\n' "$PROJECT_DIR/${d#./}" ;; esac } job_validate_name() { case "$1" in ''|*[!A-Za-z0-9_-]*) echo "Der Name darf nur Buchstaben, Ziffern, _ und - enthalten."; return 1 ;; esac return 0 } job_validate_cron() { local n; n="$(printf '%s' "$1" | awk '{print NF}')" [ "$n" = 5 ] || { echo "Ein Cron-Ausdruck besteht aus genau fuenf Feldern, z.B. '15 2 * * *'."; return 1; } return 0 } job_validate_field() { # keine Trennzeichen in Feldern case "$1" in *'|'*) echo "Das Zeichen | ist in diesem Feld nicht erlaubt."; return 1 ;; esac return 0 } # Job schreiben (anlegen oder ersetzen) job_put() { # name sites cron dir opts keep enabled jobs_init local f tmp; f="$(jobs_file)" tmp="$(mktemp)" awk -F'|' -v n="$1" '$1!=n' "$f" >"$tmp" printf '%s|%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7" >>"$tmp" # Kommentarkopf erhalten { grep -E '^[[:space:]]*#' "$f" 2>/dev/null || true; grep -vE '^[[:space:]]*(#|$)' "$tmp" ; } >"$f" rm -f "$tmp" } job_remove() { local f tmp; f="$(jobs_file)" [ -f "$f" ] || return 0 tmp="$(mktemp)" awk -F'|' -v n="$1" '$1!=n' "$f" >"$tmp" && mv "$tmp" "$f" } # --- Aufraeumen im Zielverzeichnis eines Jobs ------------------------- prune_in_dir() { # verzeichnis anzahl local dir="$1" keep="$2" i=0 [ -n "$keep" ] && [ "$keep" -gt 0 ] 2>/dev/null || return 0 local -a files=() mapfile -t files < <(find "$dir" -maxdepth 1 -type f -name '*.tar.gz' -printf '%T@ %p\n' 2>/dev/null \ | sort -rn | cut -d' ' -f2-) [ ${#files[@]} -le "$keep" ] && return 0 for ((i=keep; i<${#files[@]}; i++)); do log "Aufbewahrung: entferne $(basename "${files[$i]}")" rm -f "${files[$i]}" done } # --- Job ausfuehren --------------------------------------------------- job_run_one() { local name="$1" line line="$(job_line "$name")" [ -n "$line" ] || die "Job '$name' ist nicht vorhanden. Uebersicht: $SELF job list" local sites cron dir opts keep enabled sites="$(job_f "$line" 2)"; cron="$(job_f "$line" 3)"; dir="$(job_f "$line" 4)" opts="$(job_f "$line" 5)"; keep="$(job_f "$line" 6)"; enabled="$(job_f "$line" 7)" dir="$(job_dir_abs "$dir")" mkdir -p "$dir" || die "Zielverzeichnis nicht anlegbar: $dir" log "Job '$name' - Ziel $dir, Sites ${sites}${opts:+, Optionen $opts}" require_cmk_running local -a args=(--dir "$dir" --label "$name") local o for o in $opts; do case "$o" in --no-rrds|--no-logs|--stop) args+=("$o") ;; *) warn "Unbekannte Option '$o' im Job '$name' - wird uebergangen." ;; esac done if [ "$sites" = ALL ]; then args+=(--all) else local sname for sname in $(printf '%s' "$sites" | tr ',' ' '); do args+=(--site "$sname"); done fi ASSUME_YES=1 cmd_backup "${args[@]}" prune_in_dir "$dir" "$keep" ok "Job '$name' abgeschlossen." } # --- Cron ------------------------------------------------------------ cron_write() { jobs_init local n=0 line name cron local tmp; tmp="$(mktemp)" { printf '# Automatische Checkmk-Backups - erzeugt von cmk-manage.sh\n' printf '# Nicht von Hand aendern, Quelle ist %s\n' "$(jobs_file)" printf 'SHELL=/bin/sh\n' printf 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n' while IFS= read -r line; do [ -n "$line" ] || continue [ "$(job_f "$line" 7)" = on ] || continue name="$(job_f "$line" 1)"; cron="$(job_f "$line" 3)" printf '%s root %s -y job run %s >>/var/log/cmk-backup.log 2>&1\n' \ "$cron" "$SELF" "$name" n=$((n+1)) done < <(jobs_read) } >"$tmp" if [ "$n" -eq 0 ]; then rm -f "$tmp" warn "Kein aktiver Job vorhanden - es wird nichts eingeplant." [ -f "$CRON_FILE" ] && { rm -f "$CRON_FILE"; log "Vorhandene Cron-Datei entfernt."; } return 0 fi if [ "$(id -u)" -ne 0 ]; then err "Zum Schreiben von $CRON_FILE werden root-Rechte benoetigt." echo cat "$tmp" echo log "Die Zeilen oben von Hand nach $CRON_FILE uebernehmen, oder erneut mit sudo aufrufen." rm -f "$tmp" return 1 fi install -m 0644 "$tmp" "$CRON_FILE" rm -f "$tmp" ok "$n Job(s) eingeplant in $CRON_FILE" } cron_remove_file() { if [ -f "$CRON_FILE" ]; then [ "$(id -u)" -eq 0 ] || die "Zum Entfernen von $CRON_FILE werden root-Rechte benoetigt." rm -f "$CRON_FILE" ok "Automatische Backups abgeschaltet ($CRON_FILE entfernt)." else ok "Es war nichts eingeplant." fi } backup_auto_enabled() { case "$(printf '%s' "${BACKUP_AUTO:-off}" | tr 'A-Z' 'a-z')" in on|true|1|yes|ja) return 0 ;; *) return 1 ;; esac } # --- Anzeige ---------------------------------------------------------- jobs_list_table() { printf '%-14s %-12s %-14s %-28s %-18s %5s %s\n' \ "NAME" "SITES" "ZEITPLAN" "ZIEL" "OPTIONEN" "HALT" "AKTIV" local line n=0 while IFS= read -r line; do [ -n "$line" ] || continue printf '%-14s %-12s %-14s %-28s %-18s %5s %s\n' \ "$(job_f "$line" 1)" "$(job_f "$line" 2)" "$(job_f "$line" 3)" \ "$(job_f "$line" 4)" "$(job_f "$line" 5)" "$(job_f "$line" 6)" "$(job_f "$line" 7)" n=$((n+1)) done < <(jobs_read) [ "$n" -eq 0 ] && echo "(noch keine Jobs angelegt)" return 0 } cmd_job() { local sub="${1:-list}"; [ $# -gt 0 ] && shift || true case "$sub" in list|ls) jobs_list_table ;; status) printf '\n%sAutomatische Backups%s\n' "$C_BLD" "$C_RST" printf ' Schalter : %s\n' "$(backup_auto_enabled && echo 'ein (BACKUP_AUTO=on)' || echo 'aus (BACKUP_AUTO=off)')" printf ' Cron-Datei : %s\n' "$([ -f "$CRON_FILE" ] && echo "$CRON_FILE" || echo 'nicht vorhanden')" printf ' Jobdatei : %s\n' "$(jobs_file)" printf ' Manuell nach: %s (BACKUP_DIR)\n' "$BACKUP_DIR" printf '\n%sJobs%s\n' "$C_BLD" "$C_RST" jobs_list_table if [ -f "$CRON_FILE" ]; then printf '\n%sEingeplant%s\n' "$C_BLD" "$C_RST" grep -vE '^[[:space:]]*(#|SHELL|PATH|$)' "$CRON_FILE" | sed 's/^/ /' fi echo ;; add|set) local name="" sites="ALL" cron="" dir="" keep="7" enabled="on" opts="" local is_set=""; [ "$sub" = set ] && is_set=1 while [ $# -gt 0 ]; do case "$1" in --name) name="$2"; shift 2 ;; --sites) sites="$2"; shift 2 ;; --cron) cron="$2"; shift 2 ;; --dir) dir="$2"; shift 2 ;; --keep) keep="$2"; shift 2 ;; --no-rrds) opts="$opts --no-rrds"; shift ;; --no-logs) opts="$opts --no-logs"; shift ;; --stop) opts="$opts --stop"; shift ;; --enabled) enabled=on; shift ;; --disabled) enabled=off; shift ;; *) [ -z "$name" ] && { name="$1"; shift; } || die "Unbekannte Option fuer 'job $sub': $1" ;; esac done [ -n "$name" ] || die "--name fehlt." local msg msg="$(job_validate_name "$name")" || die "$msg" local old=""; old="$(job_line "$name")" if [ -n "$is_set" ]; then [ -n "$old" ] || die "Job '$name' ist nicht vorhanden." # nicht angegebene Felder aus dem Bestand uebernehmen if [ "$sites" = ALL ]; then sites="$(job_f "$old" 2)"; fi if [ -z "$cron" ]; then cron="$(job_f "$old" 3)"; fi if [ -z "$dir" ]; then dir="$(job_f "$old" 4)"; fi if [ -z "$opts" ]; then opts="$(job_f "$old" 5)"; fi if [ "$keep" = 7 ]; then keep="$(job_f "$old" 6)"; fi enabled="$(job_f "$old" 7)" else [ -z "$old" ] || die "Job '$name' existiert bereits. Aendern mit: $SELF job set $name ..." fi dir="${dir:-$BACKUP_DIR}" [ -n "$cron" ] || die "--cron fehlt, z.B. --cron '15 2 * * *'." msg="$(job_validate_cron "$cron")" || die "$msg" msg="$(job_validate_field "$dir")" || die "$msg" case "$keep" in ''|*[!0-9]*) die "--keep braucht eine Zahl (0 = alles behalten)." ;; esac opts="$(printf '%s' "$opts" | sed 's/^ *//')" job_put "$name" "$sites" "$cron" "$dir" "$opts" "$keep" "$enabled" ok "Job '$name' gespeichert." jobs_list_table | sed -n "1p;/^$name /p" backup_auto_enabled && cron_write || log "Einplanen mit: $SELF job auto on" ;; del|rm|delete) [ $# -ge 1 ] || die "Name fehlt: $SELF job del NAME" job_exists "$1" || die "Job '$1' ist nicht vorhanden." confirm "Job '$1' wirklich loeschen?" || die "Abgebrochen." job_remove "$1"; ok "Job '$1' geloescht." backup_auto_enabled && cron_write || true ;; enable|disable) [ $# -ge 1 ] || die "Name fehlt: $SELF job $sub NAME" local l; l="$(job_line "$1")" [ -n "$l" ] || die "Job '$1' ist nicht vorhanden." local st=on; [ "$sub" = disable ] && st=off job_put "$1" "$(job_f "$l" 2)" "$(job_f "$l" 3)" "$(job_f "$l" 4)" \ "$(job_f "$l" 5)" "$(job_f "$l" 6)" "$st" ok "Job '$1' ist jetzt $st." backup_auto_enabled && cron_write || true ;; run) [ $# -ge 1 ] || die "Name fehlt: $SELF job run NAME" job_run_one "$1" ;; run-all) local line n=0 while IFS= read -r line; do [ -n "$line" ] || continue [ "$(job_f "$line" 7)" = on ] || continue job_run_one "$(job_f "$line" 1)" || warn "Job fehlgeschlagen: $(job_f "$line" 1)" n=$((n+1)) done < <(jobs_read) [ "$n" -eq 0 ] && warn "Kein aktiver Job vorhanden." return 0 ;; auto) case "${1:-}" in on) env_set BACKUP_AUTO on; BACKUP_AUTO=on; cron_write ;; off) env_set BACKUP_AUTO off; BACKUP_AUTO=off; cron_remove_file ;; '') printf ' Automatische Backups: %s\n' \ "$(backup_auto_enabled && echo ein || echo aus)" ;; *) die "Aufruf: $SELF job auto [on|off]" ;; esac ;; *) die "Unbekanntes Unterkommando 'job $sub'. Moeglich: list | status | add | set | del | enable | disable | run | run-all | auto" ;; esac } # ===================================================================== # Auflisten / Aufraeumen # ===================================================================== list_backup_files() { find "$BACKUP_DIR" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.tgz' \) -printf '%T@ %p\n' 2>/dev/null \ | sort -rn | cut -d' ' -f2- } cmd_list() { local f n=0 printf '%-58s %10s %s\n' "DATEI" "GROESSE" "SITE / VERSION" while IFS= read -r f; do [ -n "$f" ] || continue bundle_read "$f" 2>/dev/null || continue printf '%-58s %10s %s / %s\n' "$(basename "$f")" \ "$(human_size "$(file_size "$f")")" "$B_SITE" "${B_CMK_VERSION:-?}" n=$((n+1)) done < <(list_backup_files) [ "$n" -eq 0 ] && echo "(keine Backups in $BACKUP_DIR)" return 0 } cmd_prune() { local keep=5 while [ $# -gt 0 ]; do case "$1" in --keep) keep="$2"; shift 2 ;; *) die "Unbekannte Option fuer prune: $1" ;; esac done local -a files=(); mapfile -t files < <(list_backup_files) if [ ${#files[@]} -le "$keep" ]; then ok "Nichts zu loeschen (${#files[@]} Backups, behalte $keep)." return 0 fi local i for ((i=keep; i<${#files[@]}; i++)); do if confirm "Backup '$(basename "${files[$i]}")' loeschen?"; then rm -f "${files[$i]}" && log "geloescht: $(basename "${files[$i]}")" fi done ok "Aufraeumen beendet." } # ===================================================================== # Restore / Import (gemeinsame Implementierung) # ===================================================================== do_restore() { local file="$1" as_site="$2" reuse="$3" kill_opt="$4" nostart="$5" mode="$6" [ -f "$file" ] || die "Datei nicht gefunden: $file" require_cmk_running bundle_read "$file" || die "'$file' ist kein Checkmk-Backup (weder Bundle noch omd-Archiv)." local target="${as_site:-$B_SITE}" echo bundle_show "$file" echo printf '%sZiel-Site im Container : %s%s\n' "$C_BLD" "$target" "$C_RST" # --- Pruefsumme ------------------------------------------------- if [ "$B_IS_BUNDLE" = 1 ] && [ -n "$B_ARCHIVE_SHA256" ]; then log "Pruefe Pruefsumme ..." local sum sum="$(bundle_extract_payload "$file" | sha256sum | awk '{print $1}')" [ "$sum" = "$B_ARCHIVE_SHA256" ] || die "Pruefsumme stimmt nicht - Bundle beschaedigt." ok "Pruefsumme korrekt." fi # --- Versionspruefung ------------------------------------------- if [ -n "$B_CMK_VERSION" ]; then local installed installed="$(cmk_versions_installed)" if ! ( set +o pipefail; printf '%s\n' "$installed" | grep -qxF "$B_CMK_VERSION" ); then err "Die Checkmk-Version des Backups ist im Container nicht installiert." printf ' benoetigt : %s\n' "$B_CMK_VERSION" printf ' installiert : %s\n' "$(printf '%s' "$installed" | tr '\n' ' ')" local want_ver want_ed want_ver="${B_CMK_VERSION%.*}" case "$B_CMK_VERSION" in *.cre) want_ed=raw ;; *.cee) want_ed=enterprise ;; *.cce) want_ed=cloud ;; *.cme) want_ed=managed ;; *) want_ed="${CMK_EDITION:-raw}" ;; esac cat < --password EOF die "Abbruch wegen Versionskonflikt." fi ok "Version $B_CMK_VERSION ist im Container vorhanden." fi # --- Editionspruefung ------------------------------------------- case "$B_CMK_VERSION" in *.cee|*.cme|*.cce) warn "Das Backup stammt aus einer $(edition_from_version "$B_CMK_VERSION"). Dafuer wird das passende Image benoetigt - die Raw-Edition genuegt nicht. Aktuell konfiguriert: ${CMK_IMAGE:-}" ;; esac # --- vorhandene Site -------------------------------------------- local -a rargs=() if cmk_site_exists "$target"; then if [ -z "$reuse" ]; then die "Site '$target' existiert bereits. Mit --reuse ueberschreiben (Daten gehen verloren!)." fi warn "Die vorhandene Site '$target' wird UEBERSCHRIEBEN." confirm "Wirklich fortfahren?" || die "Abgebrochen." rargs+=(--reuse) [ -n "$kill_opt" ] && rargs+=(--kill) if [ "$(cmk_site_state "$target")" != "2" ]; then log "Stoppe Site '$target' ..." ct omd stop "$target" >/dev/null 2>&1 || true fi else confirm "Site '$target' jetzt aus dem Backup wiederherstellen?" || die "Abgebrochen." fi if [ -n "$as_site" ] && [ "$as_site" != "$B_SITE" ]; then warn "Die Site wird von '$B_SITE' nach '$as_site' umbenannt. Registrierte Agents verweisen auf '$B_SITE' und muessen neu registriert werden!" confirm "Trotzdem umbenennen?" || die "Abgebrochen." rargs+=("$as_site") fi # --- Nutzdaten in den Container schieben ------------------------ local tmp; tmp="$(mktemp -d)" log "Bereite Nutzdaten vor ..." bundle_extract_payload "$file" >"$tmp/payload.tar.gz" log "Kopiere Archiv in den Container ($(human_size "$(file_size "$tmp/payload.tar.gz")")) ..." ct sh -c 'rm -f /var/tmp/cmkrestore.tar.gz' $RUNTIME cp "$tmp/payload.tar.gz" "$CMK_CONTAINER_NAME:/var/tmp/cmkrestore.tar.gz" rm -rf "$tmp" log "Fuehre 'omd restore ${rargs[*]-} ' aus ..." if ! ct omd restore ${rargs[@]+"${rargs[@]}"} /var/tmp/cmkrestore.tar.gz; then ct sh -c 'rm -f /var/tmp/cmkrestore.tar.gz' || true die "omd restore ist fehlgeschlagen." fi ct sh -c 'rm -f /var/tmp/cmkrestore.tar.gz' || true ok "Site '$target' wurde wiederhergestellt." # --- Nachbereitung ---------------------------------------------- # TMPFS im Container abschalten (Mount im Container nicht moeglich/noetig) ct omd config "$target" set TMPFS off >/dev/null 2>&1 || true if [ -z "$nostart" ]; then log "Starte Site '$target' ..." ct omd start "$target" || warn "Site konnte nicht vollstaendig gestartet werden - bitte 'omd status $target' pruefen." fi post_restore_hints "$target" "$mode" } post_restore_hints() { local target="$1" mode="$2" ip; ip="$(primary_ip)" cat < Forward to http://127.0.0.1:5000 4) Agents: solange Site-Name (${target}) und IP-Adresse gleich bleiben und Port 8000 (Agent-Receiver) offen ist, muessen die Agents NICHT neu registriert werden - die Site-Zertifikate sind im Backup enthalten. Pruefen: $0 omd su $target -> cmk -O ; danach im GUI die Host-Status/Agent-Verbindungen kontrollieren. EOF } cmd_restore() { local file="" as_site="" reuse="" kill_opt="" nostart="" while [ $# -gt 0 ]; do case "$1" in --file) file="$2"; shift 2 ;; --as-site) as_site="$2"; shift 2 ;; --reuse) reuse=1; shift ;; --kill) kill_opt=1; shift ;; --no-start) nostart=1; shift ;; *) die "Unbekannte Option fuer restore: $1" ;; esac done [ -n "$file" ] || die "--file fehlt." do_restore "$file" "$as_site" "$reuse" "$kill_opt" "$nostart" "restore" } cmd_import() { local file="" reuse="" kill_opt="" nostart="" setdefault="" while [ $# -gt 0 ]; do case "$1" in --file) file="$2"; shift 2 ;; --reuse) reuse=1; shift ;; --kill) kill_opt=1; shift ;; --no-start) nostart=1; shift ;; --set-default-site) setdefault=1; shift ;; *) die "Unbekannte Option fuer import: $1" ;; esac done [ -n "$file" ] || die "--file fehlt." bundle_read "$file" || die "'$file' ist kein gueltiges Migrations-Bundle." if [ "$B_IS_BUNDLE" = 1 ] && [ "$B_BUNDLE_TYPE" = "migrate" ]; then log "Migrations-Bundle erkannt (Quelle: ${B_SOURCE_HOST:-?} / ${B_SOURCE_IP:-?})." local myip; myip="$(primary_ip)" if [ -n "$B_SOURCE_IP" ] && [ "$B_SOURCE_IP" != "$myip" ]; then warn "Die IP dieses Hosts ($myip) weicht von der Quell-IP ($B_SOURCE_IP) ab. Damit registrierte Agents ohne Neuregistrierung weiterarbeiten, muss dieser Host die IP $B_SOURCE_IP uebernehmen (bzw. der DNS-Name darauf zeigen)." confirm "Trotzdem importieren?" || die "Abgebrochen." fi fi do_restore "$file" "" "$reuse" "$kill_opt" "$nostart" "import" if [ -n "$setdefault" ]; then env_set CMK_SITE_ID "$B_SITE" ok "CMK_SITE_ID=$B_SITE in der .env gesetzt." warn "Damit die Aenderung wirkt, den Container neu erstellen: $0 up" fi } # ===================================================================== # NGINX Proxy Manager ein-/ausschalten # # Wer bereits einen NGINX auf einem anderen System betreibt, braucht den # Container nicht. Abgeschaltet wird er weder gebaut noch gestartet - # "compose up" bekommt dann nur noch den Checkmk-Dienst genannt. # # Die Daten unter ./data/npm bleiben in jedem Fall erhalten: sie liegen # in Bind-Mounts, nicht im Container. Ein spaeteres Einschalten nimmt # die vorhandene Konfiguration samt Zertifikaten wieder auf. # ===================================================================== # Container entfernen (nicht die Daten!), damit ihn keine Restart-Regel # und kein Reboot wieder hochzieht. npm_teardown_container() { container_exists "$NPM_CONTAINER_NAME" || return 0 if container_running "$NPM_CONTAINER_NAME"; then log "Stoppe '$NPM_CONTAINER_NAME' ..." $RUNTIME stop "$NPM_CONTAINER_NAME" >/dev/null 2>&1 || true fi log "Entferne den Container '$NPM_CONTAINER_NAME' (die Daten bleiben)." $RUNTIME rm -f "$NPM_CONTAINER_NAME" >/dev/null 2>&1 || true } npm_data_summary() { local d="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}" case "$d" in /*) : ;; *) d="$PROJECT_DIR/${d#./}" ;; esac local le="${NPM_LE_DIR:-$PROJECT_DIR/data/npm/letsencrypt}" case "$le" in /*) : ;; *) le="$PROJECT_DIR/${le#./}" ;; esac printf ' Konfiguration/DB : %s (%s)\n' "$d" "$([ -d "$d" ] && du -sh "$d" 2>/dev/null | cut -f1 || echo 'leer')" printf ' Zertifikate : %s (%s)\n' "$le" "$([ -d "$le" ] && du -sh "$le" 2>/dev/null | cut -f1 || echo 'leer')" } # Proxy Manager auf Anfang setzen: Datenbank weg, damit der Container das # Konto beim naechsten Start neu anlegt. Zertifikate bleiben erhalten. cmd_npm_reset() { local d="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}" case "$d" in /*) : ;; *) d="$PROJECT_DIR/${d#./}" ;; esac if ! npm_db_exists; then ok "Es gibt noch keine Datenbank - der Container legt das Konto beim Start selbst an." log "Vorher setzen: $SELF config set NPM_ADMIN_EMAIL " return 0 fi cat </dev/null 2>&1 || true fi mv "$d/database.sqlite" "$d/database.sqlite.alt-$stamp" ok "Datenbank beiseitegelegt: database.sqlite.alt-$stamp" log "Jetzt neu starten: $SELF up" } cmd_npm_enable() { local want="" while [ $# -gt 0 ]; do case "$1" in on|an|true|1|yes|ja) want=on; shift ;; off|aus|false|0|no|nein) want=off; shift ;; -h|--help) cat <<'EOF' cmk-manage.sh npm-enable [on|off] Schaltet den NGINX Proxy Manager ein oder aus. Ohne Angabe wird der aktuelle Stand gezeigt. off Container wird gestoppt und entfernt und bei "up" nicht mehr angelegt. Die Daten unter ./data/npm bleiben erhalten. Sinnvoll, wenn die HTTPS-Terminierung ein anderer NGINX uebernimmt - der leitet dann auf http://:5000 weiter. on Container wird wieder gebaut und gestartet, mit der vorhandenen Konfiguration und den vorhandenen Zertifikaten. EOF return 0 ;; *) die "Unbekanntes Argument fuer npm-enable: $1 (on|off)" ;; esac done if [ -z "$want" ]; then printf '\n%sNGINX Proxy Manager%s\n' "$C_BLD" "$C_RST" printf ' Schalter : %s\n' "$(npm_enabled && echo 'ein (NPM_ENABLED=on)' || echo 'aus (NPM_ENABLED=off)')" if container_exists "$NPM_CONTAINER_NAME"; then printf ' Container : %s\n' "$(container_running "$NPM_CONTAINER_NAME" && echo laeuft || echo gestoppt)" else printf ' Container : nicht vorhanden\n' fi printf '\n%sVorhandene Daten%s\n' "$C_BLD" "$C_RST" npm_data_summary echo return 0 fi if [ "$want" = off ]; then npm_enabled || { ok "Der Proxy Manager ist bereits abgeschaltet."; return 0; } printf '\n%sDiese Daten bleiben erhalten%s\n' "$C_BLD" "$C_RST" npm_data_summary echo confirm "Den NGINX Proxy Manager jetzt abschalten?" || die "Abgebrochen." env_set NPM_ENABLED off NPM_ENABLED=off npm_teardown_container ok "Abgeschaltet. Bei 'up' wird der Container nicht mehr angelegt." local ip; ip="$(primary_ip)" cat < to any port 5000 proto tcp Im externen NGINX (Beispiel): location / { proxy_pass http://$ip:5000; proxy_set_header Host \$host; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; client_max_body_size 0; proxy_read_timeout 300s; } EOF return 0 fi # --- einschalten --- if npm_enabled && container_running "$NPM_CONTAINER_NAME"; then ok "Der Proxy Manager laeuft bereits." return 0 fi env_set NPM_ENABLED on NPM_ENABLED=on log "Starte den NGINX Proxy Manager ..." if compose up -d nginx-proxy-manager; then ok "Eingeschaltet." printf '\n%sVorhandene Daten wurden uebernommen%s\n' "$C_BLD" "$C_RST" npm_data_summary printf '\n Oberflaeche: http://%s:%s/\n\n' "$(primary_ip)" "${NPM_ADMIN_PORT:-81}" else err "Der Container konnte nicht gestartet werden." log "Pruefen: $SELF logs nginx-proxy-manager" return 1 fi } # ===================================================================== # Admin-Port des NGINX Proxy Managers # # Die Adminoberflaeche des Proxy Managers lauscht im Image fest auf Port 81 # ("listen 81 default;" in /etc/nginx/conf.d/production.conf). Eine # Umgebungsvariable dafuer gibt es in den veroeffentlichten 2.x-Versionen # noch nicht. Da beide Container im Host-Netzwerk laufen, laesst sich der # Port auch nicht per Port-Mapping verschieben. # # Loesung: nginx bindet in der Datei # /data/nginx/custom/http.conf (= ./data/npm/data/nginx/custom/http.conf) # einen offiziellen Erweiterungspunkt innerhalb des http{}-Blocks ein. # Dort legen wir einen zusaetzlichen Server an, der auf dem gewuenschten # Port lauscht und an die Oberflaeche auf 127.0.0.1:81 weiterreicht. # # Port 81 bleibt dabei lokal bestehen - er wird per Firewall dichtgehalten. # ===================================================================== NPM_CUSTOM_CONF_REL="nginx/custom/http.conf" NPM_BLOCK_START="# >>> cmk-manage.sh: Admin-Port - nicht von Hand aendern >>>" NPM_BLOCK_END="# <<< cmk-manage.sh: Admin-Port <<<" # Die Zusatzkonfiguration liegt im Datenverzeichnis des Proxy Managers, # und das gehoert dem Container - also root. Ohne Schreibrecht scheitert # sonst erst "touch" mit einer nackten Fehlermeldung. npm_conf_writable() { # datei local f="$1" d; d="$(dirname "$f")" if [ -e "$f" ]; then [ -w "$f" ] && return 0 elif [ -d "$d" ]; then [ -w "$d" ] && return 0 else mkdir -p "$d" 2>/dev/null && return 0 fi err "Keine Schreibrechte auf $f" printf ' Das Verzeichnis gehoert dem Proxy-Manager-Container (root). Bitte mit sudo aufrufen: sudo %s %s\n' "$SELF" "${CMD_LINE:-}" >&2 return 1 } npm_data_dir_abs() { local base="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}" case "$base" in /*) : ;; *) base="$PROJECT_DIR/${base#./}" ;; esac printf '%s\n' "$base" } npm_custom_conf_path() { printf '%s/%s\n' "$(npm_data_dir_abs)" "$NPM_CUSTOM_CONF_REL" } NPM_DEF_START="# >>> cmk-manage.sh: HTTPS ohne SNI - nicht von Hand aendern >>>" NPM_DEF_END="# <<< cmk-manage.sh: HTTPS ohne SNI <<<" # Vorhandenen verwalteten Block entfernen, Fremdinhalte bleiben erhalten npm_conf_strip_marked() { local f="$1" st="$2" en="$3" [ -f "$f" ] || return 0 awk -v s="$st" -v e="$en" ' $0 == s { skip=1; next } $0 == e { skip=0; next } !skip { print } ' "$f" > "$f.tmp" && mv "$f.tmp" "$f" } npm_conf_strip_block() { npm_conf_strip_marked "$1" "$NPM_BLOCK_START" "$NPM_BLOCK_END"; } npm_conf_write_block() { local f="$1" port="$2" npm_conf_writable "$f" || return 1 mkdir -p "$(dirname "$f")" touch "$f" npm_conf_strip_block "$f" { printf '%s\n' "$NPM_BLOCK_START" cat <> "$f" # Leerzeilen am Anfang aufraeumen sed -i '/./,$!d' "$f" } port_in_use() { command -v ss >/dev/null 2>&1 || return 1 ( set +o pipefail; ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$1\$" ) } valid_port() { case "$1" in ''|*[!0-9]*) return 1 ;; esac [ "$1" -ge 1 ] && [ "$1" -le 65535 ] } cmd_npm_port() { local port="" restart=1 while [ $# -gt 0 ]; do case "$1" in --no-restart) restart=""; shift ;; -h|--help) cat <<'EOF' cmk-manage.sh npm-port [PORT] [--no-restart] Legt fest, auf welchem Port die Adminoberflaeche des NGINX Proxy Managers erreichbar ist. Ohne PORT wird der aktuelle Stand angezeigt. 81 = Vorgabe, kein Zusatzeintrag noetig sonst = zusaetzlicher nginx-Server auf diesem Port (Port 81 bleibt lokal bestehen und sollte per Firewall dichtgehalten werden) EOF return 0 ;; *) port="$1"; shift ;; esac done local conf; conf="$(npm_custom_conf_path)" if [ -z "$port" ]; then printf '\n%sAdminoberflaeche des NGINX Proxy Managers%s\n' "$C_BLD" "$C_RST" printf ' Konfigurierter Port : %s\n' "${NPM_ADMIN_PORT:-81}" printf ' Adresse : %s\n' "${NPM_ADMIN_URL:-http://127.0.0.1:81}" printf ' Zusatzkonfiguration : %s\n' \ "$(grep -qsF "$NPM_BLOCK_START" "$conf" && echo "$conf" || echo 'keine (Vorgabe 81)')" if [ "${NPM_ADMIN_PORT:-81}" != 81 ]; then printf ' Port 81 lokal : %s\n' "$(port_in_use 81 && echo 'lauscht (per Firewall sperren)' || echo 'lauscht nicht')" fi echo return 0 fi valid_port "$port" || die "'$port' ist keine gueltige Portnummer (1-65535)." npm_enabled || warn "Der NGINX Proxy Manager ist abgeschaltet (NPM_ENABLED=off). Die Einstellung wird gespeichert, wirkt aber erst nach dem Einschalten: $SELF npm-enable on" case "$port" in 80|443) die "Port $port wird vom Proxy Manager bereits fuer HTTP/HTTPS genutzt." ;; 5000) die "Port 5000 gehoert der Checkmk-Oberflaeche." ;; 8000) die "Port 8000 gehoert dem Checkmk-Agent-Receiver." ;; 6557) die "Port 6557 gehoert Livestatus." ;; esac if [ "$port" != 81 ] && port_in_use "$port"; then warn "Auf Port $port lauscht bereits ein Dienst." confirm "Trotzdem fortfahren?" || die "Abgebrochen." fi if [ "$port" = 81 ]; then npm_conf_strip_block "$conf" [ -s "$conf" ] || rm -f "$conf" ok "Zusatzkonfiguration entfernt - es gilt wieder die Vorgabe (Port 81)." else npm_conf_write_block "$conf" "$port" ok "Zusatzkonfiguration geschrieben: $conf" fi env_set NPM_ADMIN_PORT "$port" env_set NPM_ADMIN_URL "http://127.0.0.1:$port" NPM_ADMIN_PORT="$port"; NPM_ADMIN_URL="http://127.0.0.1:$port" if [ -n "$restart" ] && container_running "$NPM_CONTAINER_NAME"; then log "Starte '$NPM_CONTAINER_NAME' neu, damit nginx die Konfiguration liest ..." if $RUNTIME restart "$NPM_CONTAINER_NAME" >/dev/null; then ok "Proxy Manager neu gestartet." else warn "Neustart fehlgeschlagen - bitte manuell: $SELF restart" fi else log "Wirksam nach: $SELF up (bzw. Neustart des Proxy Managers)" fi local ip; ip="$(primary_ip)" cat < "kategorie|key|typ|label|auswahl|leer" settings_defs | awk -F'|' -v k="$1" '$2==k {print; exit}' } settings_value() { # KEY -> aktueller Wert eval "printf '%s' \"\${$1-}\"" } settings_display() { # KEY TYP -> Anzeigewert (Kennwoerter maskiert) local v; v="$(settings_value "$1")" if [ "$2" = password ] && [ -n "$v" ]; then printf '********' elif [ -z "$v" ]; then printf '' else printf '%s' "$v" fi } settings_validate() { # TYP WERT LEER_ERLAUBT -> 0/1, Meldung auf stdout local typ="$1" val="$2" opt="$3" if [ -z "$val" ]; then [ -n "$opt" ] && return 0 echo "Der Wert darf nicht leer sein."; return 1 fi case "$typ" in port) valid_port "$val" || { echo "Keine gueltige Portnummer (1-65535)."; return 1; } ;; int) case "$val" in ''|*[!0-9]*) echo "Bitte eine Zahl angeben."; return 1 ;; esac ;; url) case "$val" in http://*|https://*) : ;; *) echo "Bitte mit http:// oder https:// beginnen."; return 1 ;; esac ;; esac return 0 } # Zusaetzliche, schluesselbezogene Pruefung (ueber den Typ hinaus) settings_validate_key() { local key="$1" val="$2" case "$key" in CMK_RUNTIME) [ -z "$val" ] && return 0 command -v "$val" >/dev/null 2>&1 \ || { echo "'$val' ist auf diesem System nicht installiert."; return 1; } ;; CMK_SITE_ID) case "$val" in *[!a-zA-Z0-9_]*) echo "Ein Site-Name darf nur Buchstaben, Ziffern und _ enthalten."; return 1 ;; esac [ "${#val}" -le 16 ] || { echo "Ein Site-Name darf hoechstens 16 Zeichen lang sein."; return 1; } ;; FW_EXTRA_PORTS) [ -z "$val" ] && return 0 local e pp while IFS= read -r e; do e="$(printf '%s' "$e" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" [ -n "$e" ] || continue pp="${e%%=*}" case "$pp" in [0-9]*/tcp|[0-9]*/udp|[0-9]*) : ;; *) echo "'$pp' ist kein Port. Erwartet wird 3000/tcp=Grafana,5667/tcp=NSCA" return 1 ;; esac case "${pp%/*}" in *[!0-9]*) echo "'$pp' ist keine Portnummer."; return 1 ;; esac done < <(printf '%s\n' "$val" | tr ',' '\n') ;; CMK_IMAGE|NPM_IMAGE|BROWSH_IMAGE|CARBONYL_IMAGE) if [ -n "$val" ] && image_is_short_name "$val"; then echo "Bitte vollqualifiziert angeben (z.B. docker.io/...) - Podman findet kurze Namen sonst nicht."; return 1 fi ;; esac return 0 } # Nacharbeit nach dem Setzen bestimmter Schluessel settings_post_set() { local key="$1" val="$2" case "$key" in NPM_ADMIN_PORT) # Der nginx-Zusatzblock muss mitgezogen werden cmd_npm_port "$val" ;; NPM_ENABLED) # Container entsprechend entfernen bzw. starten cmd_npm_enable "$val" ;; BACKUP_AUTO) cmd_job auto "$val" ;; CMK_IMAGE|CMK_SITE_ID|CMK_CONTAINER_NAME|NPM_IMAGE|NPM_CONTAINER_NAME|TZ|CMK_LIVESTATUS_TCP|NPM_DISABLE_IPV6|CMK_DATA_DIR|NPM_DATA_DIR|NPM_LE_DIR) log "Wirksam nach: $SELF up (Container werden neu erstellt)" ;; esac } cmd_config() { local sub="${1:-list}"; [ $# -gt 0 ] && shift || true case "$sub" in list|ls) local cat_filter="" if [ "${1:-}" = "--category" ]; then cat_filter="$2"; fi local line c k t l while IFS='|' read -r c k t l _ _; do [ -n "$k" ] || continue [ -n "$cat_filter" ] && [ "$c" != "$cat_filter" ] && continue printf '%-24s %-10s %s\n' "$k" "$c" "$(settings_display "$k" "$t")" done < <(settings_defs) ;; get) [ $# -ge 1 ] || die "Schluessel fehlt: $SELF config get KEY" [ -n "$(settings_def_for "$1")" ] || die "Unbekannter Schluessel '$1'. Uebersicht: $SELF config list" settings_value "$1"; echo ;; set) [ $# -ge 2 ] || die "Aufruf: $SELF config set KEY WERT" local key="$1" val="$2" def typ opt msg def="$(settings_def_for "$key")" [ -n "$def" ] || die "Unbekannter Schluessel '$key'. Uebersicht: $SELF config list" typ="$(printf '%s' "$def" | cut -d'|' -f3)" opt="$(printf '%s' "$def" | cut -d'|' -f6)" [ "$val" = "auto" ] && [ "$key" = CMK_RUNTIME ] && val="" if ! msg="$(settings_validate "$typ" "$val" "$opt")"; then die "$msg" fi if ! msg="$(settings_validate_key "$key" "$val")"; then die "$msg" fi if [ "$key" = NPM_ADMIN_PORT ]; then cmd_npm_port "$val" elif [ "$key" = NPM_ENABLED ]; then cmd_npm_enable "$val" elif [ "$key" = BACKUP_AUTO ]; then env_set "$key" "$val"; BACKUP_AUTO="$val"; cmd_job auto "$val" else env_set "$key" "$val" "$opt" ok "$key=$val" settings_post_set "$key" "$val" fi ;; edit) local f="${ENV_FILE:-$PROJECT_DIR/.env}" "${EDITOR:-nano}" "$f"; load_env "$f"; ok "Konfiguration neu eingelesen." ;; *) die "Unbekanntes Unterkommando 'config $sub'. Moeglich: list [--category KAT] | get KEY | set KEY WERT | edit" ;; esac } # ===================================================================== # Terminal-Browser im Container # # Zweck: den NGINX Proxy Manager direkt auf der Maschine einrichten, # wenn dessen Adminoberflaeche (Port 81) von aussen nicht erreichbar ist. # # WICHTIG: Die Adminoberflaeche des Proxy Managers ist eine JavaScript- # Anwendung. Klassische Textbrowser (links2, w3m, lynx) koennen sie NICHT # darstellen. Dafuer gibt es zwei Engines, die einen echten Browser im # Terminal rendern: # browsh - Firefox im Terminal (gepflegt) # carbonyl - Chromium im Terminal (schoenere Darstellung, Stand 2023) # Die Textbrowser bleiben fuer schnelle Pruefungen nuetzlich. # # Der Container laeuft im Host-Netzwerk und erreicht damit 127.0.0.1:81 # genau wie der Proxy Manager selbst. # ===================================================================== browser_tools_image() { if [ -n "${BROWSER_TOOLS_IMAGE:-}" ]; then printf '%s\n' "$BROWSER_TOOLS_IMAGE" elif [ "$RUNTIME" = podman ]; then # Podman legt selbst gebaute Images unter localhost/ ab printf '%s\n' "localhost/checkmk-stack/browser-tools:1" else # Docker wuerde ein "localhost/"-Praefix als echte Registry deuten printf '%s\n' "checkmk-stack/browser-tools:1" fi } browser_build_tools() { local img; img="$(browser_tools_image)" local ctx="$PROJECT_DIR/containers/browser" [ -f "$ctx/Containerfile" ] || die "Containerfile fehlt: $ctx/Containerfile" log "Baue Werkzeug-Image '$img' ..." # -f explizit: Docker sucht sonst nach "Dockerfile", Podman nimmt beides $RUNTIME build -f "$ctx/Containerfile" -t "$img" "$ctx" \ || die "Bau des Werkzeug-Images fehlgeschlagen." ok "Image gebaut: $img" } browser_ensure_tools() { local img; img="$(browser_tools_image)" image_exists_local "$img" || browser_build_tools } # Antwortet die Startseite ueberhaupt? Ein Terminal-Browser zeigt sonst # nur eine schwarze Seite - ohne jede Fehlermeldung. browser_url_reachable() { command -v curl >/dev/null 2>&1 || return 0 # ohne curl nicht pruefbar local code # ACHTUNG: curl gibt bei einem Fehler selbst schon "000" aus. Ein # zusaetzliches "|| echo 000" wuerde einen zweiten Wert anhaengen und # jeden Vergleich verfaelschen - deshalb den Zuweisungsfehler abfangen. code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 6 -L "$1" 2>/dev/null)" || code=000 [ -n "$code" ] && [ "$code" != "000" ] } # Erklaert, warum nichts antwortet, und nennt den passenden naechsten Schritt browser_diagnose_url() { # url -> mehrzeiliger Text auf stdout local url="$1" host port host="$(printf '%s' "$url" | sed -E 's|^[a-z]+://([^/:]+).*|\1|')" port="$(printf '%s' "$url" | sed -nE 's|^[a-z]+://[^/:]+:([0-9]+).*|\1|p')" printf 'Auf %s antwortet nichts.\n' "$url" printf 'Ein Terminal-Browser zeigt dann nur eine schwarze Seite.\n\n' case "$host" in 127.0.0.1|localhost|::1) if [ "${port:-81}" = "${NPM_ADMIN_PORT:-81}" ]; then printf 'Das ist die Oberflaeche des NGINX Proxy Managers.\n' if ! npm_enabled; then printf 'Er ist abgeschaltet (NPM_ENABLED=off):\n' printf ' %s npm-enable on\n' "$SELF" elif ! container_exists "$NPM_CONTAINER_NAME"; then printf 'Sein Container existiert nicht:\n' printf ' %s up\n' "$SELF" elif ! container_running "$NPM_CONTAINER_NAME"; then printf 'Sein Container ist gestoppt:\n' printf ' %s up\n' "$SELF" else printf 'Der Container laeuft, antwortet aber nicht auf Port %s.\n' "${port:-81}" printf ' %s logs %s\n' "$SELF" "$NPM_CONTAINER_NAME" printf ' %s npm-port (eingestellten Port pruefen)\n' "$SELF" fi elif [ "${port:-}" = 5000 ]; then printf 'Das ist die Checkmk-Oberflaeche.\n' if ! container_running "$CMK_CONTAINER_NAME"; then printf 'Der Checkmk-Container laeuft nicht:\n %s up\n' "$SELF" else printf 'Der Container laeuft - Site gestartet?\n %s sites\n' "$SELF" fi else printf 'Auf diesem Host lauscht nichts auf Port %s.\n' "${port:-80}" printf ' ss -tlnp | grep %s\n' "${port:-80}" fi ;; *) printf 'Externe Adressen brauchen Netzzugang vom Host aus\n' printf '(der Container nutzt dessen Netzwerk direkt).\n' printf ' curl -I %s\n' "$url" ;; esac } cmd_browser() { local engine="${BROWSER_ENGINE:-browsh}" local url="" build="" build_only="" pull="" mono="" nocheck="" while [ $# -gt 0 ]; do case "$1" in -e|--engine) engine="$2"; shift 2 ;; -u|--url) url="$2"; shift 2 ;; --build) build=1; shift ;; --build-only) build=1; build_only=1; shift ;; --pull) pull=1; shift ;; --monochrome) mono=1; shift ;; --no-check) nocheck=1; shift ;; -h|--help) cat <<'EOF' cmk-manage.sh browser - Terminal-Browser im Container -e, --engine ENGINE browsh Firefox im Terminal (Vorgabe, kann JavaScript) carbonyl Chromium im Terminal (kann JavaScript) links links2 (Textbrowser, KEIN JavaScript) w3m w3m (Textbrowser, KEIN JavaScript) lynx lynx (Textbrowser, KEIN JavaScript) shell Bash mit curl/jq/openssl im Host-Netzwerk -u, --url URL Startseite (Vorgabe: $NPM_ADMIN_URL, sonst http://127.0.0.1:81 = Proxy-Manager-Oberflaeche) --build Werkzeug-Image neu bauen und danach starten --build-only Werkzeug-Image nur bauen (ohne Terminal, z.B. im Skript) --pull Image der gewaehlten Engine neu laden --monochrome nur bei browsh: einfarbige Darstellung --no-check Startseite nicht vorab auf Erreichbarkeit pruefen Bedienung browsh: Strg+L Adresszeile, Strg+T neuer Tab, Strg+W Tab schliessen, Strg+Q beenden EOF return 0 ;; *) die "Unbekannte Option fuer browser: $1" ;; esac done url="${url:-${NPM_ADMIN_URL:-http://127.0.0.1:81}}" # Engine-Namen zuerst pruefen, damit ein Tippfehler auch ohne # Terminal eine brauchbare Meldung ergibt case "$engine" in browsh|carbonyl|links|links2|w3m|lynx|shell) : ;; *) die "Unbekannte Engine '$engine'. Moeglich: browsh | carbonyl | links | w3m | lynx | shell" ;; esac # Bauen braucht kein Terminal - deshalb vor der TTY-Pruefung if [ -n "$build" ]; then browser_build_tools [ -n "$build_only" ] && return 0 fi if [ ! -t 0 ] || [ ! -t 1 ]; then die "Ein Terminal-Browser braucht ein echtes Terminal (TTY). Bitte direkt auf der Konsole bzw. in einer SSH-Sitzung aufrufen, nicht in einer Pipe oder aus einem Skript heraus." fi local image local -a args=() runopts=() case "$engine" in browsh) image="${BROWSH_IMAGE:-docker.io/browsh/browsh:v1.8.3}" args=(--startup-url "$url") [ -n "$mono" ] && args+=(--monochrome) ;; carbonyl) image="${CARBONYL_IMAGE:-docker.io/fathyb/carbonyl:latest}" args=("$url") ;; links|links2) browser_ensure_tools; image="$(browser_tools_image)"; args=(links2 "$url") ;; w3m) browser_ensure_tools; image="$(browser_tools_image)"; args=(w3m "$url") ;; lynx) browser_ensure_tools; image="$(browser_tools_image)"; args=(lynx "$url") ;; shell) browser_ensure_tools; image="$(browser_tools_image)"; args=(/bin/bash) ;; esac if [ -n "$pull" ]; then case "$engine" in browsh|carbonyl) log "Lade $image ..."; $RUNTIME pull "$image" || die "Konnte '$image' nicht laden." ;; *) browser_build_tools ;; esac fi case "$engine" in links|w3m|lynx) warn "Textbrowser koennen kein JavaScript - die Oberflaeche des NGINX Proxy Managers laesst sich damit NICHT bedienen. Dafuer: $SELF browser --engine browsh" ;; esac # Nicht erreichbare Startseite ist die haeufigste Ursache fuer eine # scheinbar haengende, schwarze Anzeige. if [ -z "$nocheck" ] && ! browser_url_reachable "$url"; then echo warn "$(browser_diagnose_url "$url")" echo confirm "Trotzdem starten?" || die "Abgebrochen." fi log "Starte $engine im Host-Netzwerk -> $url" [ "$engine" = browsh ] && log "Beenden mit Strg+Q, Adresszeile mit Strg+L." runopts=(--rm -it --network host -e "TERM=${TERM:-xterm-256color}" -e "COLORTERM=${COLORTERM:-truecolor}") $RUNTIME run "${runopts[@]}" "$image" "${args[@]}" } # ===================================================================== # Proxy-Hosts ueber die REST-Schnittstelle des NGINX Proxy Managers # # Die Weboberflaeche laesst sich im Terminal-Browser nur muehsam # bedienen - Datei-Uploads und einzelne Dialoge funktionieren dort # nicht. Da der Proxy Manager ohnehin lokal laeuft, ist die API der # verlaesslichere Weg. # # Voraussetzung ist ein Konto. Seit Version 2.15 legt der Container es # nur noch an, wenn INITIAL_ADMIN_EMAIL und INITIAL_ADMIN_PASSWORD # gesetzt sind (das erledigt die .env ueber NPM_ADMIN_EMAIL/-PASSWORD) # - sonst erscheint beim ersten Aufruf ein Einrichtungsassistent. # # Geprueft an der Schnittstellenbeschreibung von v2.15.1: # POST /api/tokens {identity, secret} # GET|POST|DELETE /api/nginx/proxy-hosts # POST /api/nginx/certificates {provider, ...} # POST /api/nginx/certificates//upload multipart # Let's Encrypt nimmt die E-Mail des angemeldeten Kontos - ein # "letsencrypt_email" im Aufruf gibt es in 2.15 nicht mehr. # ===================================================================== npm_api_base() { printf 'http://127.0.0.1:%s\n' "${NPM_ADMIN_PORT:-81}"; } # Hat der Proxy Manager schon eine Datenbank? Davon haengt ab, wie das # Konto entsteht: der Container legt es NUR beim ersten Start mit leerer # Datenbank an, und nur wenn INITIAL_ADMIN_EMAIL/-PASSWORD gesetzt sind. # Ist die Datenbank schon da, hilft nur noch der Einrichtungsassistent im # Browser - und danach muessen dessen Zugangsdaten in die .env. npm_db_exists() { local d="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}" case "$d" in /*) : ;; *) d="$PROJECT_DIR/${d#./}" ;; esac [ -s "$d/database.sqlite" ] } # Sehr einfache Auswertung - kein jq noetig, das fehlt auf vielen Hosts json_num() { printf '%s' "$1" | tr ',' '\n' | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" | head -n1; } json_str() { printf '%s' "$1" | tr ',' '\n' | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1; } # IDs aus einer Listenantwort ziehen. "id": trifft dabei NICHT auf # certificate_id oder owner_user_id zu, weil dort ein _ vor dem id steht. json_ids() { printf '%s' "$1" | grep -o '"id":[0-9]*' | sed 's/.*://' ; } npm_api_token() { local base; base="$(npm_api_base)" if [ -z "${NPM_ADMIN_EMAIL:-}" ] || [ -z "${NPM_ADMIN_PASSWORD:-}" ]; then err "Es sind keine Zugangsdaten fuer den Proxy Manager hinterlegt." printf '\n' >&2 if npm_db_exists; then cat >&2 < $SELF config set NPM_ADMIN_PASSWORD Alternativ ganz neu beginnen - VERWIRFT die Proxy-Konfiguration: $SELF npm-reset EOF else cat >&2 < $SELF config set NPM_ADMIN_PASSWORD $SELF up EOF fi return 1 fi local body body="$(curl -sS --max-time 20 -X POST "$base/api/tokens" \ -H 'Content-Type: application/json' \ -d "{\"identity\":\"$NPM_ADMIN_EMAIL\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}" 2>/dev/null)" || true local tok; tok="$(json_str "$body" token)" if [ -z "$tok" ]; then err "Anmeldung am Proxy Manager fehlgeschlagen." local m; m="$(json_str "$body" message)" [ -n "$m" ] && printf ' Antwort: %s\n' "$m" >&2 printf ' Geprueft wurde: %s\n' "$base/api/tokens" >&2 return 1 fi printf '%s' "$tok" } # npm_api METHODE PFAD [JSON] -> Antwort auf stdout, HTTP-Code via npm_code NPM_CODE_FILE="${TMPDIR:-/tmp}/.cmk-manage-npmcode.$$" trap 'rm -f "$NPM_CODE_FILE"' EXIT npm_api() { local method="$1" path="$2" data="${3:-}" local base; base="$(npm_api_base)" local -a a=(-sS --max-time 60 -X "$method" -H "Authorization: Bearer $NPM_TOKEN" -w '\n%{http_code}') [ -n "$data" ] && a+=(-H 'Content-Type: application/json' -d "$data") local out; out="$(curl "${a[@]}" "$base$path" 2>/dev/null)" || out=$'\n000' NPM_CODE="${out##*$'\n'}" # Wird npm_api in einer Kommandoersetzung aufgerufen - und das ist der # Regelfall, weil die Antwort gebraucht wird -, laeuft sie in einer # Subshell. Die Zuweisung oben ist dann beim Rueckkehren wieder weg. # Deshalb zusaetzlich ablegen; gelesen wird mit npm_code. printf '%s' "$NPM_CODE" > "$NPM_CODE_FILE" 2>/dev/null || true printf '%s' "${out%$'\n'*}" } # HTTP-Code des letzten API-Aufrufs - auch nach einer Kommandoersetzung npm_code() { local c; c="$(cat "$NPM_CODE_FILE" 2>/dev/null || true)" printf '%s' "${c:-${NPM_CODE:-000}}" } npm_require_running() { npm_enabled || { err "Der NGINX Proxy Manager ist abgeschaltet (NPM_ENABLED=off)." log "Einschalten: $SELF npm-enable on"; return 1; } container_running "$NPM_CONTAINER_NAME" || { err "Der Container '$NPM_CONTAINER_NAME' laeuft nicht." log "Starten: $SELF up"; return 1; } return 0 } # --- Zertifikat anlegen -------------------------------------------------- # gibt die Zertifikats-ID auf stdout aus npm_cert_selfsigned() { # domain local domain="$1" local dir="$PROJECT_DIR/data/npm/custom-certs/$domain" if [ ! -f "$dir/fullchain.pem" ] || [ ! -f "$dir/privkey.pem" ]; then log "Erzeuge selbstsigniertes Zertifikat fuer '$domain' ..." >&2 cmd_cert --domain "$domain" >/dev/null || return 1 else log "Verwende vorhandenes Zertifikat unter $dir" >&2 fi # Bereits vorhandenen Eintrag gleichen Namens wiederverwenden, sonst # sammeln sich bei jedem Aufruf neue Zertifikate an. local id="" body list cid o list="$(npm_api GET /api/nginx/certificates)" for cid in $(json_ids "$list"); do o="$(npm_api GET "/api/nginx/certificates/$cid")" if [ "$(json_str "$o" provider)" = other ] && [ "$(json_str "$o" nice_name)" = "$domain" ]; then id="$cid"; log "Verwende vorhandenen Zertifikatseintrag $id wieder." >&2; break fi done if [ -z "$id" ]; then body="$(npm_api POST /api/nginx/certificates \ "{\"provider\":\"other\",\"nice_name\":\"$domain\",\"domain_names\":[\"$domain\"]}")" id="$(json_num "$body" id)" [ -n "$id" ] || { err "Zertifikatseintrag konnte nicht angelegt werden (HTTP $(npm_code))." printf ' %s\n' "$body" >&2; return 1; } fi log "Lade Schluessel und Zertifikat hoch (ID $id) ..." >&2 local up up="$(curl -sS --max-time 60 -X POST "$(npm_api_base)/api/nginx/certificates/$id/upload" \ -H "Authorization: Bearer $NPM_TOKEN" \ -F "certificate=@$dir/fullchain.pem" \ -F "certificate_key=@$dir/privkey.pem" 2>/dev/null)" || true case "$up" in *'"error"'*|'') err "Hochladen fehlgeschlagen."; printf ' %s\n' "$up" >&2; return 1 ;; esac printf '%s' "$id" } npm_cert_letsencrypt() { # domain local domain="$1" body log "Fordere Let's-Encrypt-Zertifikat fuer '$domain' an - das dauert ..." >&2 body="$(npm_api POST /api/nginx/certificates \ "{\"provider\":\"letsencrypt\",\"domain_names\":[\"$domain\"],\"meta\":{}}")" local id; id="$(json_num "$body" id)" if [ -z "$id" ]; then err "Let's Encrypt hat kein Zertifikat ausgestellt (HTTP $(npm_code))." local m; m="$(json_str "$body" message)" [ -n "$m" ] && printf ' Antwort: %s\n' "$m" >&2 cat >&2 < JSON-Objekt auf stdout local spec="$1" path target scheme=http host port fwd="" case "$spec" in *=*) path="${spec%%=*}"; target="${spec#*=}" ;; *) err "Ungueltige Angabe: '$spec'" printf ' Erwartet: /pfad=host:port z.B. /grafana=127.0.0.1:3000\n' >&2 return 1 ;; esac case "$path" in /*) : ;; *) path="/$path" ;; esac case "$target" in http://*) scheme=http; target="${target#http://}" ;; https://*) scheme=https; target="${target#https://}" ;; esac host="${target%%:*}" local rest="${target#*:}" if [ "$rest" = "$target" ]; then err "Im Ziel '$target' fehlt der Port (erwartet host:port)."; return 1 fi port="${rest%%/*}" case "$rest" in */*) fwd="/${rest#*/}" ;; esac valid_port "$port" || { err "'$port' ist keine gueltige Portnummer."; return 1; } [ -n "$host" ] || { err "Im Ziel '$target' fehlt der Host."; return 1; } printf '{"path":"%s","forward_scheme":"%s","forward_host":"%s","forward_port":%s,"forward_path":"%s","advanced_config":""}' \ "$path" "$scheme" "$host" "$port" "$fwd" } # --- Proxy-Hosts --------------------------------------------------------- # --------------------------------------------------------------------- # Zwei kleine JSON-Helfer, die mehr koennen als ein sed-Ausdruck # # "advanced_config" darf geschweifte Klammern enthalten (nginx-Bloecke), # und Zeichenketten duerfen maskierte Anfuehrungszeichen tragen. Ein # Aufteilen an "}" wuerde daran zerbrechen. Diese beiden zaehlen die # Verschachtelung mit und achten auf Zeichenketten. # --------------------------------------------------------------------- # Text fuer eine JSON-Zeichenkette absichern. Anfuehrungszeichen werden # maskiert, echte Zeilenumbrueche werden zu \n - ein vom Benutzer selbst # getipptes \n bleibt, was es ist: ein Zeilenumbruch fuer nginx. json_str_escape() { printf '%s' "$1" | sed 's/"/\\"/g' | awk '{ if (NR>1) printf "\\n"; printf "%s", $0 }' } # Rohen Wert eines Schluessels der obersten Ebene ausschneiden json_raw() { # json schluessel printf '%s' "$1" | awk -v key="$2" ' { s = s $0 } function cut(s, p, n, c, k, ch, d2, q2, e2) { c = substr(s, p, 1) if (c == "{" || c == "[") { d2 = 0; q2 = 0; e2 = 0 for (k = p; k <= n; k++) { ch = substr(s, k, 1) if (q2) { if (e2) e2 = 0; else if (ch == "\\") e2 = 1; else if (ch == "\"") q2 = 0; continue } if (ch == "\"") { q2 = 1; continue } if (ch == "{" || ch == "[") d2++ else if (ch == "}" || ch == "]") { d2--; if (d2 == 0) return substr(s, p, k - p + 1) } } return substr(s, p) } if (c == "\"") { e2 = 0 for (k = p + 1; k <= n; k++) { ch = substr(s, k, 1) if (e2) { e2 = 0; continue } if (ch == "\\") { e2 = 1; continue } if (ch == "\"") return substr(s, p, k - p + 1) } return substr(s, p) } for (k = p; k <= n; k++) { ch = substr(s, k, 1) if (ch == "," || ch == "}" || ch == "]") return substr(s, p, k - p) } return substr(s, p) } END { t = "\"" key "\""; tl = length(t); n = length(s) d = 0; q = 0; e = 0 for (i = 1; i <= n; i++) { c = substr(s, i, 1) if (q) { if (e) e = 0; else if (c == "\\") e = 1; else if (c == "\"") q = 0; continue } if (c == "\"") { if (d == 1 && substr(s, i, tl) == t) { j = i + tl while (j <= n && index(" \t:", substr(s, j, 1)) > 0) j++ print cut(s, j, n); exit } q = 1; continue } if (c == "{" || c == "[") d++ else if (c == "}" || c == "]") d-- } }' } # JSON-Wahrheitswert eines Schluessels als on/off (fuer Auswahllisten) json_bool_on() { # json schluessel case "$(json_raw "$1" "$2")" in true) printf 'on' ;; *) printf 'off' ;; esac } # Ein JSON-Feld in seine Elemente zerlegen, je Element eine Zeile json_split_array() { # array-text printf '%s' "$1" | awk ' { s = s $0 } END { n = length(s); d = 0; q = 0; e = 0; start = 0 for (i = 1; i <= n; i++) { c = substr(s, i, 1) if (q) { if (e) e = 0; else if (c == "\\") e = 1; else if (c == "\"") q = 0; continue } if (c == "\"") { q = 1; continue } if (c == "[" || c == "{") { d++; if (d == 2 && start == 0) start = i; continue } if (c == "]" || c == "}") { d-- if (d == 1 && start > 0) { print substr(s, start, i - start + 1); start = 0 } } } }' } # ===================================================================== # HTTPS ueber die blanke IP # # Ruft man den Server mit https:/// auf, sendet der Browser keinen # SNI-Namen - RFC 6066 laesst dort keine IP-Adressen zu. nginx kann den # passenden Server-Block dann nicht ueber den Namen auswaehlen und faellt # auf den Standard-Server zurueck. Bei NPM ist das der Block aus # /etc/nginx/conf.d/default.conf, und der antwortet mit # "ssl_reject_handshake on" - im Browser als # ERR_SSL_UNRECOGNIZED_NAME_ALERT. # # Ueber HTTP faellt das nicht auf: dort waehlt nginx den Server-Block # anhand des Host-Kopfes aus, und der traegt die IP. # # Abhilfe ist ein eigener Block mit "default_server" in der # Zusatzkonfiguration, die nginx nach den Proxy-Hosts einliest. Keiner # der NPM-Bloecke setzt das Kennzeichen selbst, deshalb gibt es keinen # Streit darum. Benannte Aufrufe gehen weiterhin an ihren Proxy-Host - # der Standard-Server greift nur, wenn kein Name passt. # ===================================================================== # Zertifikatspfade IM CONTAINER zu einer NPM-Zertifikats-ID npm_cert_paths() { # id -> "fullchain|privkey|anbieter" local cid="$1" o prov o="$(npm_api GET "/api/nginx/certificates/$cid")" || return 1 prov="$(json_str "$o" provider)" if [ "$prov" = letsencrypt ]; then printf '/etc/letsencrypt/live/npm-%s/fullchain.pem|/etc/letsencrypt/live/npm-%s/privkey.pem|%s\n' \ "$cid" "$cid" "$prov" else printf '/data/custom_ssl/npm-%s/fullchain.pem|/data/custom_ssl/npm-%s/privkey.pem|%s\n' \ "$cid" "$cid" "${prov:-other}" fi } # Deckt das Zertifikat die IP ab? Nur pruefbar, wenn die Datei hier liegt. npm_cert_covers_ip() { # zertifikats-id ip local cid="$1" ip="$2" f f="$(npm_data_dir_abs)/custom_ssl/npm-$cid/fullchain.pem" [ -r "$f" ] || return 0 # nicht pruefbar - dann auch nicht meckern ( set +o pipefail; openssl x509 -in "$f" -noout -text 2>/dev/null | grep -q "IP Address:$ip" ) } # Einen Proxy-Host in einen Standard-Server-Block spiegeln npm_default_write() { # conf objekt-json fullchain privkey domain local f="$1" o="$2" full="$3" key="$4" dom="$5" npm_conf_writable "$f" || return 1 local sch host port seg lp lsch lh lpo sch="$(json_str "$o" forward_scheme)"; [ -n "$sch" ] || sch=http host="$(json_str "$o" forward_host)" port="$(json_num "$o" forward_port)" mkdir -p "$(dirname "$f")" touch "$f" npm_conf_strip_marked "$f" "$NPM_DEF_START" "$NPM_DEF_END" { printf '%s\n' "$NPM_DEF_START" cat <> "$f" sed -i '/./,$!d' "$f" } npm_reload_hint() { # restart-flag if [ -n "${1:-}" ] && container_running "$NPM_CONTAINER_NAME"; then log "Starte '$NPM_CONTAINER_NAME' neu, damit nginx die Konfiguration liest ..." if $RUNTIME restart "$NPM_CONTAINER_NAME" >/dev/null; then ok "Proxy Manager neu gestartet." else warn "Neustart fehlgeschlagen - bitte manuell: $SELF restart" fi else log "Wirksam nach: $SELF restart (bzw. Neustart des Proxy Managers)" fi } cmd_proxy() { local sub="${1:-list}"; [ $# -gt 0 ] && shift || true case "$sub" in login|status) npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 ok "Anmeldung am Proxy Manager erfolgreich ($NPM_ADMIN_EMAIL)." local body; body="$(npm_api GET /api/nginx/proxy-hosts)" printf ' Proxy-Hosts: %s\n' "$(printf '%s' "$body" | grep -c '"id"' || true)" body="$(npm_api GET /api/nginx/certificates)" printf ' Zertifikate: %s\n' "$(printf '%s' "$body" | grep -c '"id"' || true)" return 0 ;; list) npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 local body; body="$(npm_api GET /api/nginx/proxy-hosts)" printf '%-5s %-34s %-26s %s\n' "ID" "DOMAINS" "ZIEL" "SSL" local id o dom fh fp cid n=0 for id in $(json_ids "$body"); do # Einzeln abfragen: in der Listenantwort laesst sich ein Objekt # nicht zuverlaessig abgrenzen, weil "meta":{...} ebenfalls # geschweifte Klammern enthaelt. o="$(npm_api GET "/api/nginx/proxy-hosts/$id")" dom="$(printf '%s' "$o" | sed -n 's/.*"domain_names"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' | tr -d '"' || true)" fh="$(json_str "$o" forward_host)"; fp="$(json_num "$o" forward_port)" cid="$(json_num "$o" certificate_id)" printf '%-5s %-34s %-26s %s\n' "$id" "${dom:-?}" \ "$(json_str "$o" forward_scheme)://$fh:$fp" \ "$([ "${cid:-0}" -gt 0 ] 2>/dev/null && echo "ja (Zert. $cid)" || echo nein)" n=$((n+1)) done [ "$n" -eq 0 ] && echo "(noch kein Proxy-Host angelegt)" return 0 ;; certs) npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 local body; body="$(npm_api GET /api/nginx/certificates)" printf '%-5s %-12s %-28s %s\n' "ID" "ANBIETER" "NAME" "LAEUFT AB" local id o n=0 for id in $(json_ids "$body"); do o="$(npm_api GET "/api/nginx/certificates/$id")" printf '%-5s %-12s %-28s %s\n' "$id" "$(json_str "$o" provider)" \ "$(json_str "$o" nice_name)" "$(json_str "$o" expires_on | awk '{print $1}')" n=$((n+1)) done [ "$n" -eq 0 ] && echo "(noch kein Zertifikat vorhanden)" return 0 ;; show) local pid="" dom="" while [ $# -gt 0 ]; do case "$1" in --id) pid="$2"; shift 2 ;; --domain) dom="$2"; shift 2 ;; *) pid="$1"; shift ;; esac done npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 if [ -z "$pid" ]; then [ -n "$dom" ] || die "--id oder --domain angeben." local all; all="$(npm_api GET /api/nginx/proxy-hosts)" local i for i in $(json_ids "$all"); do if ( set +o pipefail; npm_api GET "/api/nginx/proxy-hosts/$i" | grep -qF "\"$dom\"" ); then pid="$i"; break; fi done [ -n "$pid" ] || die "Kein Proxy-Host fuer '$dom' gefunden." fi local o; o="$(npm_api GET "/api/nginx/proxy-hosts/$pid")" printf '\n%sProxy-Host %s%s\n' "$C_BLD" "$pid" "$C_RST" printf ' Domains : %s\n' "$(printf '%s' "$o" | sed -n 's/.*"domain_names"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' | tr -d '"' || true)" printf ' Ziel : %s://%s:%s\n' "$(json_str "$o" forward_scheme)" "$(json_str "$o" forward_host)" "$(json_num "$o" forward_port)" printf ' Zertifikat : %s\n' "$(json_num "$o" certificate_id)" printf '\n %sZusaetzliche Pfade%s\n' "$C_BLD" "$C_RST" local el n=0 while IFS= read -r el; do [ -n "$el" ] || continue n=$((n+1)) printf ' %-20s -> %s://%s:%s\n' "$(json_str "$el" path)" \ "$(json_str "$el" forward_scheme)" "$(json_str "$el" forward_host)" \ "$(json_num "$el" forward_port)" done < <(json_split_array "$(json_raw "$o" locations)") if [ "$n" -eq 0 ]; then printf ' (keine - alles geht an das Ziel oben)\n'; fi echo return 0 ;; default|ip) local dom="" off="" restart="" conf while [ $# -gt 0 ]; do case "$1" in --domain) dom="$2"; shift 2 ;; --off|--aus) off=1; shift ;; --restart) restart=1; shift ;; --help|-h) cat </ auf, schickt der Browser keinen Namen mit (SNI kennt keine IP-Adressen). nginx kann den Proxy-Host dann nicht ueber den Namen finden und faellt auf seinen Standard-Server zurueck - der lehnt den Handshake ab: ERR_SSL_UNRECOGNIZED_NAME_ALERT. Ueber HTTP faellt das nicht auf, dort entscheidet der Host-Kopf. Dieser Befehl legt einen Standard-Server an, der denselben Weg nimmt wie der genannte Proxy-Host - samt der zusaetzlichen Pfade. Aufrufe mit Namen gehen weiterhin an ihren eigenen Proxy-Host. EOF return 0 ;; *) dom="$1"; shift ;; esac done conf="$(npm_custom_conf_path)" if [ -n "$off" ]; then if ! grep -qsF "$NPM_DEF_START" "$conf"; then ok "Es ist kein Standard-Server eingetragen." return 0 fi npm_conf_strip_marked "$conf" "$NPM_DEF_START" "$NPM_DEF_END" [ -s "$conf" ] || rm -f "$conf" ok "Standard-Server entfernt: $conf" npm_reload_hint "$restart" return 0 fi if [ -z "$dom" ]; then printf '\n%sHTTPS ueber die blanke IP%s\n' "$C_BLD" "$C_RST" if grep -qsF "$NPM_DEF_START" "$conf"; then test_result ok "eingerichtet" printf ' Datei : %s\n' "$conf" printf ' Spiegelt: %s\n' \ "$(sed -n 's/^# Spiegelt den Proxy-Host "\(.*\)"\.$/\1/p' "$conf" | head -1)" printf '\n Entfernen mit: %s proxy default --off\n\n' "$SELF" else test_result warn "nicht eingerichtet" printf ' https:/// endet dann in ERR_SSL_UNRECOGNIZED_NAME_ALERT, http:/// geht.\n' printf '\n Einrichten mit: %s proxy default --domain \n' "$SELF" printf ' Vorhandene : %s proxy list\n\n' "$SELF" fi return 0 fi npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 # Proxy-Host zum Namen suchen local all pid="" i all="$(npm_api GET /api/nginx/proxy-hosts)" for i in $(json_ids "$all"); do if ( set +o pipefail; npm_api GET "/api/nginx/proxy-hosts/$i" | grep -qF "\"$dom\"" ); then pid="$i"; break fi done [ -n "$pid" ] || die "Kein Proxy-Host fuer '$dom' gefunden - '$SELF proxy list' zeigt die vorhandenen." local o cid o="$(npm_api GET "/api/nginx/proxy-hosts/$pid")" cid="$(json_num "$o" certificate_id)" if [ -z "$cid" ] || [ "$cid" = 0 ]; then die "Der Proxy-Host '$dom' hat kein Zertifikat. Ohne Zertifikat gibt es nichts, was ohne SNI ausgeliefert werden koennte. Erst eines zuweisen: $SELF proxy add --domain $dom --cert ... --update" fi local paths full key prov paths="$(npm_cert_paths "$cid")" || die "Zertifikat $cid nicht abrufbar." full="${paths%%|*}"; paths="${paths#*|}" key="${paths%%|*}"; prov="${paths##*|}" local ip; ip="$(primary_ip)" if [ "$prov" = letsencrypt ]; then warn "Das Zertifikat stammt von Lets Encrypt und lautet auf '$dom'. Beim Aufruf ueber die IP meldet der Browser deshalb einen Namensfehler (anklickbar, aber unschoen). Fuer den IP-Zugang ist ein selbstsigniertes Zertifikat mit der IP als Alternativname besser: $SELF cert --domain $dom --alt $ip" elif [ -n "$ip" ] && ! npm_cert_covers_ip "$cid" "$ip"; then warn "Das Zertifikat fuehrt die IP $ip nicht als Alternativnamen. Der Browser meldet dann einen Namensfehler. Neu erzeugen mit: $SELF cert --domain $dom --alt $ip und dem Proxy-Host zuweisen." fi CMD_LINE="proxy default --domain $dom" npm_default_write "$conf" "$o" "$full" "$key" "$dom" || return 1 ok "Standard-Server geschrieben: $conf" printf ' Spiegelt Proxy-Host %s (%s), Zertifikat %s (%s)\n' "$pid" "$dom" "$cid" "$prov" if [ -n "$ip" ]; then printf ' Danach erreichbar unter: https://%s/\n' "$ip"; fi npm_reload_hint "$restart" return 0 ;; edit|set) local pid="" dom="" cert="" adv="" advset="" rename="" local fhost="" fport="" scheme="" enabled="" local fssl="" h2="" ws="" blk="" local -a addloc=() delloc=() while [ $# -gt 0 ]; do case "$1" in --id) pid="$2"; shift 2 ;; --domain) dom="$2"; shift 2 ;; --rename) rename="$2"; shift 2 ;; --forward-host) fhost="$2"; shift 2 ;; --forward-port) fport="$2"; shift 2 ;; --scheme) scheme="$2"; shift 2 ;; --advanced) adv="$2"; advset=1; shift 2 ;; --cert) cert="$2"; shift 2 ;; --force-ssl) fssl="$2"; shift 2 ;; --http2) h2="$2"; shift 2 ;; --websockets) ws="$2"; shift 2 ;; --block-exploits) blk="$2"; shift 2 ;; --enable) enabled=true; shift ;; --disable) enabled=false; shift ;; --location) addloc+=("$2"); shift 2 ;; --del-location) delloc+=("$2"); shift 2 ;; --help|-h) cat < nur zeigen if [ -z "$rename$fhost$fport$scheme$advset$cert$fssl$h2$ws$blk$enabled" ] \ && [ "${#addloc[@]}" -eq 0 ] && [ "${#delloc[@]}" -eq 0 ]; then cmd_proxy show --id "$pid" printf ' Aendern mit: %s proxy edit --id %s --forward-port 5001 Alle Moeglichkeiten: %s proxy edit --help\n\n' "$SELF" "$pid" "$SELF" return 0 fi local -a parts=() changed=() if [ -n "$rename" ]; then parts+=("\"domain_names\":[\"$(json_str_escape "$rename")\"]") changed+=("Domain -> $rename") fi if [ -n "$fhost" ]; then parts+=("\"forward_host\":\"$(json_str_escape "$fhost")\""); changed+=("Ziel-Host -> $fhost") fi if [ -n "$fport" ]; then valid_port "$fport" || die "--forward-port: keine gueltige Portnummer." parts+=("\"forward_port\":$fport"); changed+=("Ziel-Port -> $fport") fi if [ -n "$scheme" ]; then case "$scheme" in http|https) : ;; *) die "--scheme: http oder https" ;; esac parts+=("\"forward_scheme\":\"$scheme\""); changed+=("Schema -> $scheme") fi if [ -n "$advset" ]; then parts+=("\"advanced_config\":\"$(json_str_escape "$adv")\"") changed+=("erweiterte Konfiguration ersetzt") fi local b for b in "fssl:--force-ssl:ssl_forced:HTTPS erzwingen" \ "h2:--http2:http2_support:HTTP/2" \ "ws:--websockets:allow_websocket_upgrade:WebSockets" \ "blk:--block-exploits:block_exploits:Exploit-Sperre"; do local var opt rest key label val var="${b%%:*}"; rest="${b#*:}" opt="${rest%%:*}"; rest="${rest#*:}" key="${rest%%:*}"; label="${rest#*:}" eval "val=\"\${$var}\"" [ -n "$val" ] || continue case "$(fw_lc "$val")" in on|true|1|ja|yes) val=true ;; off|false|0|nein|no) val=false ;; *) die "$opt: on oder off" ;; esac parts+=("\"$key\":$val"); changed+=("$label -> $val") done if [ -n "$enabled" ]; then parts+=("\"enabled\":$enabled") changed+=("Proxy-Host $([ "$enabled" = true ] && echo eingeschaltet || echo ausgeschaltet)") fi if [ -n "$cert" ]; then local certid="" cdom cdom="${rename:-$(printf '%s' "$o" | sed -n 's/.*"domain_names"[[:space:]]*:[[:space:]]*\[[[:space:]]*"\([^"]*\)".*/\1/p')}" case "$cert" in self) certid="$(npm_cert_selfsigned "$cdom")" || return 1 ;; letsencrypt) certid="$(npm_cert_letsencrypt "$cdom")" || return 1 ;; none) certid=0 ;; *[!0-9]*) die "--cert: self | letsencrypt | none | " ;; *) certid="$cert" ;; esac parts+=("\"certificate_id\":$certid") changed+=("Zertifikat -> $certid") if [ "$certid" = 0 ]; then parts+=("\"ssl_forced\":false" "\"http2_support\":false") changed+=("HTTPS erzwingen -> false (ohne Zertifikat nicht moeglich)") fi fi # Pfade: die API ersetzt das ganze Feld, also aus dem Bestand neu bauen if [ "${#addloc[@]}" -gt 0 ] || [ "${#delloc[@]}" -gt 0 ]; then local cur; cur="$(json_raw "$o" locations)" local -a keep=() local el elpath spec p drop while IFS= read -r el; do [ -n "$el" ] || continue elpath="$(json_str "$el" path)" drop="" for p in ${delloc[@]+"${delloc[@]}"}; do case "$p" in /*) : ;; *) p="/$p" ;; esac if [ "$p" = "$elpath" ]; then drop=1; changed+=("Pfad $elpath entfernt"); fi done for spec in ${addloc[@]+"${addloc[@]}"}; do p="${spec%%=*}"; case "$p" in /*) : ;; *) p="/$p" ;; esac if [ "$p" = "$elpath" ]; then drop=1 if [ -n "$(json_str "$el" advanced_config)" ]; then warn "Der Pfad $elpath hatte eine eigene erweiterte Konfiguration. Beim Ersetzen geht sie verloren." fi fi done [ -n "$drop" ] || keep+=("$el") done < <(json_split_array "$cur") for spec in ${addloc[@]+"${addloc[@]}"}; do local lj; lj="$(proxy_location_json "$spec")" || return 1 keep+=("$lj") changed+=("Pfad ${spec%%=*} -> ${spec#*=}") done local joined="" for el in ${keep[@]+"${keep[@]}"}; do if [ -n "$joined" ]; then joined="$joined,$el"; else joined="$el"; fi done parts+=("\"locations\":[$joined]") fi local payload="{" first=1 x for x in "${parts[@]}"; do if [ -n "$first" ]; then payload="$payload$x"; first=""; else payload="$payload,$x"; fi done payload="$payload}" log "Aendere Proxy-Host $pid ..." local body; body="$(npm_api PUT "/api/nginx/proxy-hosts/$pid" "$payload")" case "$(npm_code)" in 200|201) : ;; *) err "Aenderung fehlgeschlagen (HTTP $(npm_code))." local m; m="$(json_str "$body" message)" if [ -n "$m" ]; then printf ' Antwort: %s\n' "$m" >&2; fi return 1 ;; esac ok "Proxy-Host $pid geaendert." for x in "${changed[@]}"; do printf ' %s\n' "$x"; done # Der Proxy Manager biegt manches still zurecht - ohne Zertifikat # etwa HTTP/2 und die HTTPS-Umleitung. Das darf nicht unbemerkt # bleiben, sonst sucht man den Fehler spaeter an der falschen Stelle. local k want got for x in "${parts[@]}"; do k="${x%%:*}"; k="${k#\"}"; k="${k%\"}" case "$k" in locations|domain_names|advanced_config) continue ;; esac want="${x#*:}" got="$(json_raw "$body" "$k")" [ -n "$got" ] || continue [ "$got" != "$want" ] || continue warn "'$k': gesendet wurde $want, gespeichert ist $got." if [ "$(json_raw "$body" certificate_id)" = 0 ]; then printf ' Ohne Zertifikat laesst der Proxy Manager das nicht zu. Erst eines zuweisen: %s proxy edit --id %s --cert self\n' "$SELF" "$pid" fi done echo cmd_proxy show --id "$pid" if grep -qsF "$NPM_DEF_START" "$(npm_custom_conf_path)"; then log "Der Standard-Server fuer den IP-Zugang spiegelt einen Stand von vorher. Nach inhaltlichen Aenderungen neu schreiben: $SELF proxy default --domain --restart" fi return 0 ;; del|delete|rm) local pid="" while [ $# -gt 0 ]; do case "$1" in --id) pid="$2"; shift 2 ;; *) pid="$1"; shift ;; esac done [ -n "$pid" ] || die "ID fehlt: $SELF proxy del --id N (IDs zeigt '$SELF proxy list')" npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 confirm "Proxy-Host mit der ID $pid wirklich loeschen?" || die "Abgebrochen." npm_api DELETE "/api/nginx/proxy-hosts/$pid" >/dev/null case "$(npm_code)" in 200|201|204) ok "Proxy-Host $pid geloescht." ;; *) die "Loeschen fehlgeschlagen (HTTP $(npm_code))." ;; esac return 0 ;; add) local domain="${PROXY_DOMAIN:-}" fhost="${PROXY_FORWARD_HOST:-127.0.0.1}" local fport="${PROXY_FORWARD_PORT:-5000}" scheme=http cert=self local force_ssl=true http2=true ws=true block=true update="" adv="" local adv_set="" locs="" while [ $# -gt 0 ]; do case "$1" in --domain) domain="$2"; shift 2 ;; --forward-host) fhost="$2"; shift 2 ;; --forward-port) fport="$2"; shift 2 ;; --scheme) scheme="$2"; shift 2 ;; --cert) cert="$2"; shift 2 ;; --advanced) adv="$2"; adv_set=1; shift 2 ;; --location) local lj lj="$(proxy_location_json "$2")" || return 1 if [ -n "$locs" ]; then locs="$locs,$lj"; else locs="$lj"; fi shift 2 ;; --update) update=1; shift ;; --no-force-ssl) force_ssl=false; shift ;; --no-http2) http2=false; shift ;; --no-websockets) ws=false; shift ;; --no-block-exploits) block=false; shift ;; *) die "Unbekannte Option fuer 'proxy add': $1" ;; esac done [ -n "$domain" ] || die "--domain fehlt (oder PROXY_DOMAIN in der .env setzen)." case "$scheme" in http|https) : ;; *) die "--scheme: http oder https" ;; esac case "$cert" in none|self|letsencrypt) : ;; *) die "--cert: none | self | letsencrypt" ;; esac valid_port "$fport" || die "--forward-port: keine gueltige Portnummer." [ -n "$adv_set" ] || adv='client_max_body_size 0;\nproxy_read_timeout 300s;\nproxy_set_header X-Forwarded-Proto $scheme;\nproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' npm_require_running || return 1 NPM_TOKEN="$(npm_api_token)" || return 1 # schon vorhanden? local existing # Achtung: grep liefert 1, wenn nichts passt - unter "set -e" wuerde # das die Zuweisung und damit das ganze Skript beenden. existing="$(npm_api GET /api/nginx/proxy-hosts | tr '{' '\n' \ | grep -F "\"$domain\"" | head -n1 || true)" local exid=""; [ -n "$existing" ] && exid="$(json_num "$existing" id)" if [ -n "$exid" ] && [ -z "$update" ]; then die "Fuer '$domain' gibt es bereits den Proxy-Host $exid. Ersetzen mit --update, oder vorher loeschen: $SELF proxy del --id $exid" fi local certid=0 case "$cert" in self) certid="$(npm_cert_selfsigned "$domain")" || return 1 ;; letsencrypt) certid="$(npm_cert_letsencrypt "$domain")" || return 1 ;; none) certid=0; force_ssl=false; http2=false ;; esac local payload payload="{\"domain_names\":[\"$domain\"],\"forward_scheme\":\"$scheme\",\"forward_host\":\"$fhost\",\"forward_port\":$fport,\"certificate_id\":$certid,\"ssl_forced\":$force_ssl,\"http2_support\":$http2,\"block_exploits\":$block,\"allow_websocket_upgrade\":$ws,\"caching_enabled\":false,\"advanced_config\":\"$adv\",\"locations\":[$locs],\"enabled\":true,\"meta\":{}}" local body if [ -n "$exid" ]; then log "Aktualisiere vorhandenen Proxy-Host $exid ..." body="$(npm_api PUT "/api/nginx/proxy-hosts/$exid" "$payload")" else log "Lege Proxy-Host fuer '$domain' an ..." body="$(npm_api POST /api/nginx/proxy-hosts "$payload")" fi local newid; newid="$(json_num "$body" id)" if [ -z "$newid" ]; then err "Der Proxy-Host konnte nicht angelegt werden (HTTP $(npm_code))." local m; m="$(json_str "$body" message)" [ -n "$m" ] && printf ' Antwort: %s\n' "$m" >&2 return 1 fi ok "Proxy-Host $newid angelegt: $domain -> $scheme://$fhost:$fport" if [ -n "$locs" ]; then printf ' Zusaetzliche Pfade:\n' printf '%s' "$locs" | tr ',' '\n' | sed -n 's/.*"path":"\([^"]*\)".*"forward_host":"\([^"]*\)","forward_port":\([0-9]*\).*/ \1 -> \2:\3/p' fi cat < $([ "$force_ssl" = true ] && echo 'Weiterleitung auf HTTPS' || echo 'unverschluesselt') $([ "$cert" = self ] && printf '%s\n' "Das Zertifikat ist selbstsigniert. Damit Browser und Agents ihm trauen, data/npm/custom-certs/$domain/fullchain.pem auf den Clients als vertrauenswuerdig einrichten.") EOF return 0 ;; *) die "Unbekanntes Unterkommando 'proxy $sub'. Moeglich: status | list | show | certs | add | edit | del | default" ;; esac } # ===================================================================== # Selbstsigniertes Zertifikat fuer den NGINX Proxy Manager # ===================================================================== cmd_cert() { local domain="${CERT_DOMAIN:-checkmk.example.local}" days="${CERT_DAYS:-3650}" local -a alts=() while [ $# -gt 0 ]; do case "$1" in --domain) domain="$2"; shift 2 ;; --alt) alts+=("$2"); shift 2 ;; --days) days="$2"; shift 2 ;; *) die "Unbekannte Option fuer cert: $1" ;; esac done command -v openssl >/dev/null 2>&1 || die "openssl ist nicht installiert." local outdir="$PROJECT_DIR/data/npm/custom-certs/$domain" mkdir -p "$outdir" local san="DNS:$domain" local a for a in ${alts[@]+"${alts[@]}"}; do case "$a" in [0-9]*.[0-9]*.[0-9]*.[0-9]*) san="$san,IP:$a" ;; *) san="$san,DNS:$a" ;; esac done local myip; myip="$(primary_ip)" if [ -n "$myip" ]; then san="$san,IP:$myip"; fi log "Erzeuge selbstsigniertes Zertifikat fuer '$domain' ($days Tage) ..." openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$days" \ -keyout "$outdir/privkey.pem" -out "$outdir/fullchain.pem" \ -subj "/C=${CERT_COUNTRY:-DE}/O=${CERT_ORG:-Selfsigned}/CN=$domain" \ -addext "subjectAltName=$san" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "keyUsage=critical,digitalSignature,keyEncipherment" \ -addext "extendedKeyUsage=serverAuth" 2>/dev/null \ || die "Zertifikatserstellung fehlgeschlagen." chmod 600 "$outdir/privkey.pem" ok "Zertifikat erstellt:" printf ' Schluessel : %s\n' "$outdir/privkey.pem" printf ' Zertifikat : %s\n' "$outdir/fullchain.pem" printf ' SAN : %s\n\n' "$san" cat < SSL Certificates -> Add SSL Certificate -> Custom 2) Name z.B. "$domain" Certificate Key : privkey.pem Certificate : fullchain.pem 3) Im Proxy-Host unter "SSL" dieses Zertifikat auswaehlen, "Force SSL" und "HTTP/2 Support" aktivieren. EOF } # ===================================================================== # ncurses-Oberflaeche # ===================================================================== # --------------------------------------------------------------------- # GUI-sichere Vorbedingungen # # In der Oberflaeche darf nichts "die" aufrufen - das wuerde die ganze TUI # beenden. Diese Helfer melden das Problem im Dialog und geben 1 zurueck. # --------------------------------------------------------------------- gui_need_container() { if ! container_exists "$CMK_CONTAINER_NAME"; then ui_msg "Checkmk-Container fehlt" \ "Der Container '$CMK_CONTAINER_NAME' existiert nicht. Dafuer wird er zuerst gebraucht. Im Hauptmenue: stack -> Stack starten Auf der Kommandozeile: $SELF up" return 1 fi if ! container_running "$CMK_CONTAINER_NAME"; then ui_msg "Checkmk-Container gestoppt" \ "Der Container '$CMK_CONTAINER_NAME' existiert, laeuft aber nicht. Im Hauptmenue: stack -> Stack starten Kommandozeile: $SELF up" return 1 fi return 0 } gui_need_sites() { gui_need_container || return 1 local n; n="$(cmk_sites | sed '/^$/d' | wc -l)" if [ "$n" -eq 0 ]; then ui_msg "Keine Sites" \ "Im Container ist noch keine Site vorhanden. Der Container legt die Site aus CMK_SITE_ID beim ersten Start selbst an. Alternativ eine Site importieren: Hauptmenue -> import" return 1 fi return 0 } # Interaktives Kommando ausfuehren, ohne dass ein Fehler die TUI beendet gui_run_interactive() { clear 2>/dev/null || true if ! ( "$@" ); then printf '\n%sDas Kommando wurde mit einem Fehler beendet.%s\n' "$C_YEL" "$C_RST" fi printf '\nWeiter mit ENTER ...' read -r _ Site auf stdout gui_need_container || return 1 local -a items=() s while IFS= read -r s; do [ -n "$s" ] || continue items+=("$s" "$(cmk_site_version "$s") / $(cmk_site_state_text "$s")") done < <(cmk_sites) [ ${#items[@]} -gt 0 ] || { ui_msg "Sites" "Im Container ist keine Site vorhanden."; return 1; } ui_menu "Site waehlen" "Vorhandene Sites im Container:" "${items[@]}" } gui_pick_backup() { # -> Datei auf stdout local -a items=() f while IFS= read -r f; do [ -n "$f" ] || continue bundle_read "$f" 2>/dev/null || continue items+=("$f" "$B_SITE ${B_CMK_VERSION:-?} $(human_size "$(file_size "$f")")") done < <(list_backup_files) if [ ${#items[@]} -eq 0 ]; then ui_msg "Backups" "In $BACKUP_DIR wurde kein Backup gefunden.\n\nAlternativ kann im naechsten Schritt frei im Dateisystem gesucht werden." ui_pick_file "$PROJECT_DIR" '*.tar.gz' return fi items+=("__browse__" "... anderes Verzeichnis durchsuchen ...") local sel sel="$(ui_menu "Backup waehlen" "Backups in $BACKUP_DIR:" "${items[@]}")" || return 1 if [ "$sel" = "__browse__" ]; then ui_pick_file "$PROJECT_DIR" '*.tar.gz' else printf '%s\n' "$sel" fi } gui_status() { local f; f="$(mktemp)" NO_COLOR=1 cmd_status >"$f" 2>&1 || true ui_text "Status" "$f" rm -f "$f" } gui_stack_menu() { while :; do local sel sel="$(ui_menu "Stack steuern" "Container-Runtime: $RUNTIME" \ up "Stack starten (compose up -d)" \ restart "Stack neu starten" \ down "Stack stoppen und entfernen" \ pull "Images aktualisieren" \ logs "Letzte 200 Logzeilen ansehen" \ back "zurueck")" || return 0 case "$sel" in up) ui_run "Stack starten" bash -c "cd '$PROJECT_DIR' && '$SELF' up" ;; restart) ui_run "Neu starten" bash -c "cd '$PROJECT_DIR' && '$SELF' restart" ;; down) ui_yesno "Stack stoppen" "Container wirklich stoppen und entfernen?\n(Die Daten unter ./data bleiben erhalten.)" \ && ui_run "Stack stoppen" bash -c "cd '$PROJECT_DIR' && '$SELF' down" ;; pull) ui_run "Images laden" bash -c "cd '$PROJECT_DIR' && '$SELF' pull" ;; logs) local lf; lf="$(mktemp)"; compose logs --tail=200 >"$lf" 2>&1 || true ui_text "Logs (letzte 200 Zeilen)" "$lf"; rm -f "$lf" ;; back) return 0 ;; esac done } gui_backup() { gui_need_sites || return 0 local -a items=() s while IFS= read -r s; do [ -n "$s" ] || continue items+=("$s" "$(cmk_site_version "$s")" off) done < <(cmk_sites) [ ${#items[@]} -gt 0 ] || { ui_msg "Backup" "Keine Sites vorhanden."; return; } local chosen chosen="$(ui_checklist "Backup" "Zu sichernde Site(s) mit LEERTASTE markieren:" "${items[@]}")" || return 0 [ -n "$chosen" ] || { ui_msg "Backup" "Keine Site ausgewaehlt."; return; } local optsel optsel="$(ui_checklist "Backup-Optionen" "Optionen waehlen:" \ no-rrds "Performance-Daten (RRD) auslassen - kleineres Archiv" off \ no-logs "Logdateien auslassen" off \ stop "Site waehrend des Backups stoppen (konsistenter)" off)" || return 0 local label label="$(ui_input "Kennzeichnung" "Optionaler Text fuer den Dateinamen (leer = keiner):" "")" || label="" label="$(printf '%s' "$label" | tr -cd 'A-Za-z0-9._-')" local -a args=(backup --dir "$BACKUP_DIR") [ -n "$label" ] && args+=(--label "$label") case "$optsel" in *no-rrds*) args+=(--no-rrds);; esac case "$optsel" in *no-logs*) args+=(--no-logs);; esac case "$optsel" in *stop*) args+=(--stop);; esac local sitename for sitename in $(printf '%s' "$chosen" | tr -d '"'); do args+=(--site "$sitename") done ui_run "Backup laeuft" bash -c "cd '$PROJECT_DIR' && '$SELF' -y ${args[*]}" } # --------------------------------------------------------------------- # Backup: erstellen und planen # --------------------------------------------------------------------- gui_backup_menu() { while :; do local auto sel auto="$(backup_auto_enabled && echo 'ein' || echo 'aus')" sel="$(ui_menu "Backup erstellen / planen" \ "Manuelle Backups gehen immer nach: $BACKUP_DIR (Einstellung BACKUP_DIR) Geplante Jobs haben ihr eigenes Zielverzeichnis. Automatische Ausfuehrung: $auto" \ manuell "Backup jetzt erstellen" \ jobs "Backup-Jobs verwalten (anlegen/aendern/loeschen)" \ auto "Automatische Ausfuehrung ein/aus (derzeit: $auto)" \ status "Zeitplan und Jobs anzeigen" \ jetzt "Einen Job sofort ausfuehren" \ back "zurueck")" || return 0 case "$sel" in manuell) gui_backup || true ;; jobs) gui_jobs_menu || true ;; auto) gui_backup_auto || true ;; status) local f; f="$(mktemp)"; NO_COLOR=1 cmd_job status >"$f" 2>&1 || true ui_text "Backup-Zeitplan" "$f"; rm -f "$f" ;; jetzt) gui_job_run_now || true ;; back) return 0 ;; esac done } gui_backup_auto() { if backup_auto_enabled; then ui_yesno "Automatische Backups" \ "Automatische Backups sind eingeschaltet. Abschalten? Die Jobs bleiben erhalten, es wird nur nichts mehr automatisch ausgefuehrt." || return 0 ui_run "Automatik abschalten" bash -c "cd '$PROJECT_DIR' && '$SELF' -y job auto off" else local n; n="$(jobs_read | awk -F'|' '$7=="on"' | wc -l)" if [ "$n" -eq 0 ]; then ui_msg "Keine aktiven Jobs" \ "Es ist noch kein aktiver Backup-Job angelegt. Zuerst unter 'Backup-Jobs verwalten' einen Job anlegen - dort wird auch der Zeitplan festgelegt." return fi ui_yesno "Automatische Backups" \ "$n aktive(r) Job(s) werden in die System-Cron eingetragen ($CRON_FILE). Dafuer werden root-Rechte benoetigt. Einschalten?" || return 0 ui_run "Automatik einschalten" bash -c "cd '$PROJECT_DIR' && '$SELF' -y job auto on" fi load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_pick_job() { # -> Jobname auf stdout local -a items=() line while IFS= read -r line; do [ -n "$line" ] || continue items+=("$(job_f "$line" 1)" \ "$(job_f "$line" 3) -> $(job_f "$line" 4) [$(job_f "$line" 7)]") done < <(jobs_read) if [ ${#items[@]} -eq 0 ]; then ui_msg "Keine Jobs" "Es ist noch kein Backup-Job angelegt." return 1 fi ui_menu "Job waehlen" "Vorhandene Backup-Jobs:" "${items[@]}" } gui_job_run_now() { gui_need_sites || return 0 local j; j="$(gui_pick_job)" || return 0 [ -n "$j" ] || return 0 ui_yesno "Job ausfuehren" "Job '$j' jetzt ausfuehren?" || return 0 ui_run "Job '$j'" bash -c "cd '$PROJECT_DIR' && '$SELF' -y job run '$j'" } gui_jobs_menu() { while :; do local sel sel="$(ui_menu "Backup-Jobs" \ "Jeder Job hat eigenen Zeitplan, eigenes Zielverzeichnis und eigene Aufbewahrung. Jobdatei: $(jobs_file)" \ list "Jobs anzeigen" \ neu "Neuen Job anlegen" \ edit "Job aendern" \ toggle "Job ein-/ausschalten" \ del "Job loeschen" \ back "zurueck")" || return 0 case "$sel" in list) local f; f="$(mktemp)"; NO_COLOR=1 jobs_list_table >"$f" 2>&1 || true ui_text "Backup-Jobs" "$f"; rm -f "$f" ;; neu) gui_job_edit "" || true ;; edit) local j; j="$(gui_pick_job)" || continue; [ -n "$j" ] && gui_job_edit "$j" ;; toggle) local j2 l st j2="$(gui_pick_job)" || continue; [ -n "$j2" ] || continue l="$(job_line "$j2")"; st=disable if [ "$(job_f "$l" 7)" = off ]; then st=enable; fi ui_run "Job $st" bash -c "cd '$PROJECT_DIR' && '$SELF' -y job $st '$j2'" ;; del) local j3; j3="$(gui_pick_job)" || continue; [ -n "$j3" ] || continue ui_yesno "Loeschen" "Job '$j3' wirklich loeschen?\n\n(Bereits erstellte Backups bleiben erhalten.)" \ && ui_run "Job loeschen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y job del '$j3'" ;; back) return 0 ;; esac done } # Zeitplan bequem zusammenklicken gui_pick_cron() { # vorgabe -> cron-ausdruck auf stdout local cur="${1:-15 2 * * *}" kind time hh mm dow dom kind="$(ui_menu "Zeitplan" "Wie oft soll gesichert werden?" \ taeglich "jeden Tag" \ woechentlich "einmal pro Woche" \ monatlich "einmal pro Monat" \ frei "Cron-Ausdruck selbst eingeben")" || return 1 if [ "$kind" = frei ]; then ui_input "Cron-Ausdruck" "Fuenf Felder: Minute Stunde Tag Monat Wochentag" "$cur" return fi time="$(ui_input "Uhrzeit" "Uhrzeit im Format HH:MM" "02:15")" || return 1 case "$time" in [0-9][0-9]:[0-9][0-9]) : ;; *) ui_msg "Ungueltig" "Bitte im Format HH:MM angeben, z.B. 02:15."; return 1 ;; esac hh="${time%%:*}"; mm="${time##*:}" hh="$((10#$hh))"; mm="$((10#$mm))" [ "$hh" -le 23 ] && [ "$mm" -le 59 ] || { ui_msg "Ungueltig" "Stunde 0-23, Minute 0-59."; return 1; } case "$kind" in taeglich) printf '%s %s * * *\n' "$mm" "$hh" ;; woechentlich) dow="$(ui_menu "Wochentag" "An welchem Tag?" \ 1 Montag 2 Dienstag 3 Mittwoch 4 Donnerstag \ 5 Freitag 6 Samstag 0 Sonntag)" || return 1 printf '%s %s * * %s\n' "$mm" "$hh" "$dow" ;; monatlich) dom="$(ui_input "Tag im Monat" "Welcher Tag (1-28)?" "1")" || return 1 case "$dom" in ''|*[!0-9]*) ui_msg "Ungueltig" "Bitte eine Zahl."; return 1 ;; esac printf '%s %s %s * *\n' "$mm" "$hh" "$dom" ;; esac } gui_job_edit() { # name (leer = neu) local name="$1" line="" sites="ALL" cron="15 2 * * *" dir="$BACKUP_DIR" opts="" keep="7" if [ -n "$name" ]; then line="$(job_line "$name")" sites="$(job_f "$line" 2)"; cron="$(job_f "$line" 3)"; dir="$(job_f "$line" 4)" opts="$(job_f "$line" 5)"; keep="$(job_f "$line" 6)" else name="$(ui_input "Neuer Job" "Name des Jobs (Buchstaben, Ziffern, _ und -):" "nachts")" || return 0 local msg if ! msg="$(job_validate_name "$name")"; then ui_msg "Ungueltiger Name" "$msg"; return; fi if job_exists "$name"; then ui_msg "Schon vorhanden" "Ein Job namens '$name' existiert bereits."; return; fi fi # Sites auswaehlen if gui_need_sites; then local -a items=() sname items+=("ALL" "alle Sites (auch spaeter neu angelegte)" "$([ "$sites" = ALL ] && echo on || echo off)") while IFS= read -r sname; do [ -n "$sname" ] || continue items+=("$sname" "einzelne Site" \ "$( ( set +o pipefail; printf '%s' ",$sites," | grep -q ",$sname," ) && echo on || echo off)") done < <(cmk_sites) local chosen chosen="$(ui_checklist "Sites" "Was soll dieser Job sichern?\n(ALL schliesst alle anderen ein)" "${items[@]}")" || return 0 chosen="$(printf '%s' "$chosen" | tr -d '"')" [ -n "$chosen" ] || { ui_msg "Keine Auswahl" "Es wurde keine Site gewaehlt."; return; } case " $chosen " in *" ALL "*) sites=ALL ;; *) sites="$(printf '%s' "$chosen" | tr ' ' ',')" ;; esac fi cron="$(gui_pick_cron "$cron")" || return 0 [ -n "$cron" ] || return 0 dir="$(ui_input "Zielverzeichnis" \ "Wohin soll dieser Job sichern? (absolut oder relativ zum Projekt; wird bei Bedarf angelegt)" "$dir")" || return 0 [ -n "$dir" ] || return 0 keep="$(ui_input "Aufbewahrung" \ "Wie viele Backups sollen in diesem Verzeichnis bleiben? 0 = alle behalten" "$keep")" || return 0 case "$keep" in ''|*[!0-9]*) ui_msg "Ungueltig" "Bitte eine Zahl angeben."; return ;; esac local optsel optsel="$(ui_checklist "Optionen" "Optionen fuer diesen Job:" \ no-rrds "Performance-Daten (RRD) auslassen" "$(case "$opts" in *no-rrds*) echo on ;; *) echo off ;; esac)" \ no-logs "Logdateien auslassen" "$(case "$opts" in *no-logs*) echo on ;; *) echo off ;; esac)" \ stop "Site waehrend des Backups stoppen" "$(case "$opts" in *stop*) echo on ;; *) echo off ;; esac)")" || return 0 opts="" case "$optsel" in *no-rrds*) opts="$opts --no-rrds" ;; esac case "$optsel" in *no-logs*) opts="$opts --no-logs" ;; esac case "$optsel" in *stop*) opts="$opts --stop" ;; esac opts="$(printf '%s' "$opts" | sed 's/^ *//')" local action=add; job_exists "$name" && action=set ui_run "Job speichern" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y job $action --name '$name' --sites '$sites' \ --cron '$cron' --dir '$dir' --keep '$keep' $opts" } gui_restore() { gui_need_container || return 0 local file; file="$(gui_pick_backup)" || return 0 [ -n "$file" ] || return 0 local f; f="$(mktemp)" bundle_show "$file" >"$f" 2>&1 || true ui_text "Backup-Informationen" "$f"; rm -f "$f" bundle_read "$file" || { ui_msg "Fehler" "Diese Datei ist kein Checkmk-Backup."; return; } local reuse="" if cmk_site_exists "$B_SITE"; then ui_yesno "Site existiert" "Die Site '$B_SITE' ist bereits vorhanden.\n\nSoll sie UEBERSCHRIEBEN werden?\nAlle aktuellen Daten dieser Site gehen verloren!" || return 0 reuse="--reuse --kill" fi ui_yesno "Restore starten" "Site '$B_SITE' aus\n$(basename "$file")\nwiederherstellen?" || return 0 ui_run "Restore laeuft" bash -c "cd '$PROJECT_DIR' && '$SELF' -y restore --file '$file' $reuse" } gui_import() { gui_need_container || return 0 ui_msg "Site importieren" \ "Import einer Site von einer Direktinstallation (Bare-Metal/VM). Voraussetzung: auf dem Quellserver wurde mit 'migrate_creator.sh' ein Migrations-Bundle (.tar.gz) erzeugt und hierher kopiert. Wichtig fuer die Agents: - Site-Name bleibt unveraendert - dieser Host uebernimmt die IP/den DNS-Namen des Quellservers - Port 8000 (Agent-Receiver) ist erreichbar Dann muessen die Agents NICHT neu eingerichtet werden." local file; file="$(gui_pick_backup)" || return 0 [ -n "$file" ] || return 0 local f; f="$(mktemp)" bundle_show "$file" >"$f" 2>&1 || true ui_text "Bundle-Informationen" "$f"; rm -f "$f" bundle_read "$file" || { ui_msg "Fehler" "Diese Datei ist kein Migrations-Bundle."; return; } local reuse="" if cmk_site_exists "$B_SITE"; then ui_yesno "Site existiert" "Die Site '$B_SITE' existiert bereits im Container.\n\nUEBERSCHREIBEN?" || return 0 reuse="--reuse --kill" fi local setdef="" ui_yesno "Standard-Site" "Soll CMK_SITE_ID in der .env auf '$B_SITE' gesetzt werden?\n\n(Empfohlen - sonst startet der Container nach einem Reboot\ndie importierte Site nicht automatisch.)" \ && setdef="--set-default-site" ui_run "Import laeuft" bash -c "cd '$PROJECT_DIR' && '$SELF' -y import --file '$file' $reuse $setdef" } gui_backups_menu() { while :; do local sel sel="$(ui_menu "Backups verwalten" "Verzeichnis: $BACKUP_DIR" \ list "Backups auflisten" \ info "Details eines Backups anzeigen" \ verify "Backup pruefen (Struktur + Pruefsumme)" \ del "Backup loeschen" \ back "zurueck")" || return 0 case "$sel" in list) local f; f="$(mktemp)"; NO_COLOR=1 cmd_list >"$f" 2>&1 || true ui_text "Backups" "$f"; rm -f "$f" ;; info) local b; b="$(gui_pick_backup)" || continue; [ -n "$b" ] || continue local f2; f2="$(mktemp)"; bundle_show "$b" >"$f2" 2>&1 || true ui_text "Details" "$f2"; rm -f "$f2" ;; verify) local b2; b2="$(gui_pick_backup)" || continue; [ -n "$b2" ] || continue ui_run "Pruefe Bundle" bash -c "cd '$PROJECT_DIR' && '$SELF' verify --file '$b2'" ;; del) local b3; b3="$(gui_pick_backup)" || continue; [ -n "$b3" ] || continue ui_yesno "Loeschen" "Datei wirklich loeschen?\n\n$b3" && rm -f "$b3" \ && ui_msg "Geloescht" "Die Datei wurde entfernt." ;; back) return 0 ;; esac done } gui_sites_menu() { gui_need_container || return 0 while :; do local sel sel="$(ui_menu "Sites" "Sites im Container '$CMK_CONTAINER_NAME'" \ list "Sites anzeigen" \ start "Site starten" \ stop "Site stoppen" \ all "alle Sites starten" \ back "zurueck")" || return 0 case "$sel" in list) local f; f="$(mktemp)"; cmd_sites >"$f" 2>&1 || true; ui_text "Sites" "$f"; rm -f "$f" ;; start) local s; s="$(gui_pick_site)" || continue; [ -n "$s" ] || continue ui_run "Site starten" bash -c "cd '$PROJECT_DIR' && '$SELF' site-start '$s'" ;; stop) local s2; s2="$(gui_pick_site)" || continue; [ -n "$s2" ] || continue ui_run "Site stoppen" bash -c "cd '$PROJECT_DIR' && '$SELF' site-stop '$s2'" ;; all) ui_run "Alle Sites starten" bash -c "cd '$PROJECT_DIR' && '$SELF' start-all-sites" ;; back) return 0 ;; esac done } gui_image_menu() { while :; do local sel sel="$(ui_menu "Checkmk-Image" \ "Aktuell (.env): ${CMK_IMAGE:-} Edition/Version: ${CMK_EDITION:-raw} / ${CMK_VERSION:-?}" \ status "Status und lokale Images anzeigen" \ versions "Verfuegbare Versionen auflisten" \ holen "Image holen (Registry oder Downloadportal)" \ wizard "Assistent: Edition, Version und Bezugsweg waehlen" \ test "Zugangsdaten pruefen (Registry und Downloadportal)" \ use "Vorhandenes lokales Image auswaehlen" \ back "zurueck")" || return 0 case "$sel" in status) local f; f="$(mktemp)"; NO_COLOR=1 cmd_image status >"$f" 2>&1 || true ui_text "Image-Status" "$f"; rm -f "$f" ;; versions) gui_image_versions || true ;; holen) gui_image_get || true ;; wizard) gui_image_wizard || true ;; test) gui_image_test || true ;; use) gui_image_use || true ;; back) return 0 ;; esac done } gui_image_pick_edition() { # Checkmk hat die Editionen umbenannt: die Website zeigt die neuen # Namen, Dateien und Repositories tragen bis 2.4 die alten. Deshalb # hier beide, damit die Zuordnung eindeutig ist. ui_menu "Edition" \ "Links der Name, den dieses Werkzeug verwendet, rechts der Name auf checkmk.com." \ raw "= Community auf der Website (frei, bis 2.4)" \ community "= Community (frei, ab 2.5)" \ enterprise "= Pro auf der Website (Login noetig, bis 2.4)" \ pro "= Pro (ab 2.5)" \ cloud "= Ultimate auf der Website (bis 2.4)" \ ultimate "= Ultimate (ab 2.5)" \ managed "= Ultimate MT auf der Website (bis 2.4)" \ ultimatemt "= Ultimate MT (ab 2.5)" } # Version aus dem oeffentlichen Archiv auswaehlen, statt sie abzutippen gui_image_pick_version() { # edition [vorgabe] -> Version auf stdout local edition="$1" cur="${2:-}" local filt filt="$(ui_input "Versionen filtern" \ "Nach welchem Zweig suchen? (leer = alle) Beispiele: 2.3.0p nur Patch-Staende von 2.3.0 2.4 alles aus 2.4" "${cur%%p*}")" || return 1 local -a items=() v while IFS= read -r v; do [ -n "$v" ] || continue items+=("$v" " ") done < <(archive_versions 2>/dev/null \ | { [ -n "$filt" ] && grep -F "$filt" || cat; } | sort -rV | head -n 40) if [ ${#items[@]} -eq 0 ]; then ui_msg "Keine Versionen" \ "Aus dem Versionsarchiv von checkmk.com konnte nichts gelesen werden. Entweder passt der Filter nicht, oder der Host hat keinen Zugang zu checkmk.com. Die Version laesst sich im naechsten Schritt auch von Hand eingeben." ui_input "Version" "Version von Hand eingeben:" "$cur" return fi items+=("__manuell__" "... von Hand eingeben ...") local sel sel="$(ui_menu "Version waehlen" \ "Edition: $edition Quelle: oeffentliches Versionsarchiv von checkmk.com" "${items[@]}")" || return 1 if [ "$sel" = "__manuell__" ]; then ui_input "Version" "Version von Hand eingeben:" "$cur" else printf '%s\n' "$sel" fi } # --------------------------------------------------------------------- # Assistent: Image beschaffen # # Fuehrt in einem Durchgang durch Edition, Version und Bezugsweg und # schreibt anschliessend CMK_EDITION, CMK_VERSION und CMK_IMAGE. # Bereits hinterlegte Zugangsdaten werden verwendet - danach wird nur # gefragt, wenn sie fuer den gewaehlten Weg fehlen. # --------------------------------------------------------------------- gui_image_wizard() { local ed ver src local cur_img="${CMK_IMAGE:-}" ed="$(ui_menu "Image beschaffen (1/3) - Edition" \ "Derzeit verwendet: $cur_img Eingestellt : ${CMK_EDITION:-?} / ${CMK_VERSION:-?} Links der Name, den dieses Werkzeug verwendet, rechts der Name auf checkmk.com." \ raw "= Community auf der Website (frei, bis 2.4)" \ community "= Community (frei, ab 2.5)" \ enterprise "= Pro auf der Website (Login noetig, bis 2.4)" \ pro "= Pro (ab 2.5)" \ cloud "= Ultimate auf der Website (bis 2.4)" \ ultimate "= Ultimate (ab 2.5)" \ managed "= Ultimate MT auf der Website (bis 2.4)" \ ultimatemt "= Ultimate MT (ab 2.5)")" || return 0 ver="$(gui_image_pick_version "$ed" "${CMK_VERSION:-}")" || return 0 [ -n "$ver" ] || return 0 # --- Bezugsweg ----------------------------------------------------- local reg_state dl_state reg_state="$([ -n "${CMK_REGISTRY_USER:-}" ] && echo "Zugangsdaten hinterlegt" || echo "ohne Anmeldung")" dl_state="$([ -n "${CMK_DOWNLOAD_USER:-}" ] && echo "Zugangsdaten hinterlegt" || echo "ohne Anmeldung")" src="$(ui_menu "Image beschaffen (3/3) - Bezugsweg" \ "$ed $ver Woher soll das Image kommen? Alle vier Wege tragen CMK_IMAGE ein. Nur der letzte laedt dabei nichts herunter." \ registry "Registry holen ($reg_state)" \ download "Tarball herunterladen ($dl_state)" \ file "Vorhandene Tarball-Datei einspielen" \ setonly "Nur eintragen - geholt wird beim naechsten Start")" || return 0 # --- Zugangsdaten nur erfragen, wenn sie wirklich fehlen ----------- local -a extra=() local need_user="" u p case "$src" in registry) edition_needs_login "$ed" && [ -z "${CMK_REGISTRY_USER:-}" ] && need_user=registry ;; download) case "$ed" in raw|community|free) : ;; *) [ -z "${CMK_DOWNLOAD_USER:-}" ] && need_user=download ;; esac ;; esac if [ -n "$need_user" ]; then u="$(ui_input "Zugangsdaten fehlen" \ "Fuer diesen Weg werden Zugangsdaten des Checkmk-Kundenportals gebraucht. Sie sind noch nicht hinterlegt. Benutzer:" "")" || return 0 [ -n "$u" ] || { ui_msg "Abgebrochen" "Ohne Benutzer geht es nicht weiter."; return 0; } p="$(ui_password "Zugangsdaten fehlen" "Kennwort:")" || return 0 extra+=(--user "$u" --password "$p") if ui_yesno "Merken?" "Die Zugangsdaten fuer kuenftige Aufrufe in der .env speichern?"; then if [ "$need_user" = registry ]; then env_set CMK_REGISTRY_USER "$u"; env_set CMK_REGISTRY_PASSWORD "$p" else env_set CMK_DOWNLOAD_USER "$u"; env_set CMK_DOWNLOAD_PASSWORD "$p" fi load_env "${ENV_FILE:-$PROJECT_DIR/.env}" fi fi # --- ausfuehren ---------------------------------------------------- local rc=0 case "$src" in registry) ui_run "Image holen: $ed $ver" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image pull --edition '$ed' --version '$ver' --remember ${extra[*]+${extra[*]}}" || rc=1 ;; download) case "$ver" in *-latest|latest|*-daily) ui_msg "Nicht moeglich" \ "Fuer den Tarball-Download wird eine exakte Version gebraucht, z.B. 2.3.0p23 - '$ver' gibt es dort nicht." return 0 ;; esac ui_run "Tarball holen: $ed $ver" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image download --edition '$ed' --version '$ver' --remember ${extra[*]+${extra[*]}}" || rc=1 ;; file) local lf lf="$(ui_pick_file "${IMAGE_DIR:-$PROJECT_DIR/images}" '*.tar.gz')" || return 0 [ -n "$lf" ] || return 0 ui_run "Image einspielen" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image load --file '$lf'" || rc=1 [ "$rc" = 0 ] && ui_run "Edition/Version eintragen" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y config set CMK_EDITION '$ed' && '$SELF' -y config set CMK_VERSION '$ver'" ;; setonly) # Setzt Edition, Version UND das daraus gebildete Image. # Heruntergeladen wird nichts - das erledigt "up" bei Bedarf. ui_run "Eintragen" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image use --edition '$ed' --version '$ver'" || rc=1 ;; esac load_env "${ENV_FILE:-$PROJECT_DIR/.env}" # --- Ergebnis ------------------------------------------------------ if [ "$rc" != 0 ]; then ui_msg "Image wurde NICHT geholt" \ "Der Vorgang ist fehlgeschlagen - siehe die Ausgabe davor. CMK_IMAGE ist deshalb unveraendert: ${CMK_IMAGE:-} Naechste Schritte: * Bei der Registry: 'image repos' zeigt, welche Pfade es dort wirklich gibt. Der geratene Pfad ist die haeufigste Ursache. * Sonst den Tarball-Weg nehmen - fuer Enterprise ist das der von Checkmk dokumentierte Weg. * Zugangsdaten pruefen: '>> Zugangsdaten jetzt pruefen'" return 0 fi if [ "$src" = setonly ] && [ "$rc" = 0 ]; then ui_msg "Eingetragen" \ "Verwendet wird jetzt: ${CMK_IMAGE:-} Heruntergeladen wurde nichts - das holt die Runtime beim naechsten 'Stack steuern -> Stack starten' selbst nach. Braucht die Registry eine Anmeldung (Enterprise), muss die einmal erfolgt sein: podman login ${CMK_REGISTRY:-registry.checkmk.com} Das erledigt auch der Weg 'Registry holen'." return 0 fi ui_msg "Fertig" \ "Verwendet wird jetzt: ${CMK_IMAGE:-} Eingestellt: ${CMK_EDITION:-?} / ${CMK_VERSION:-?} Wirksam wird das mit 'Stack steuern -> Stack starten' (die Container werden dabei neu erstellt)." } # Holen, wenn Edition und Version schon feststehen: nur noch die Frage # nach dem Bezugsweg. Der volle Assistent bleibt als Wahlmoeglichkeit. gui_image_get() { if [ -z "${CMK_EDITION:-}" ] || [ -z "${CMK_VERSION:-}" ]; then ui_msg "Erst Edition und Version" \ "Es ist noch keine Edition bzw. Version eingestellt. Der Assistent fragt beides ab und laesst sich gleich anschliessen." gui_image_wizard || true return 0 fi local ref="" vorgabe if edition_known "${CMK_EDITION}" 2>/dev/null; then ref="$(edition_repo "$CMK_EDITION"):$CMK_VERSION" fi vorgabe="$(image_source_default)" # Der eingestellte Weg wird gekennzeichnet, statt ihn doppelt # aufzufuehren - sonst gaebe es zwei Eintraege mit derselben Kennung. local m_auto="erst Registry, dann Downloadportal" local m_reg="nur aus der Registry" local m_dl="nur als Tarball vom Downloadportal" case "$vorgabe" in auto) m_auto="$m_auto (eingestellt)" ;; registry) m_reg="$m_reg (eingestellt)" ;; download) m_dl="$m_dl (eingestellt)" ;; esac local sel sel="$(ui_menu "Image holen" \ "Edition/Version: ${CMK_EDITION} / ${CMK_VERSION}${ref:+ Registry-Referenz: $ref} Woher soll das Image geholt werden?" \ auto "$m_auto" \ registry "$m_reg" \ download "$m_dl" \ datei "Vorhandene Tarball-Datei einspielen" \ assistent "Etwas anderes waehlen (Edition/Version aendern)")" || return 0 case "$sel" in assistent) gui_image_wizard || true; return 0 ;; datei) local lf lf="$(ui_pick_file "${IMAGE_DIR:-$PROJECT_DIR/images}" '*.tar.gz')" || return 0 [ -n "$lf" ] || return 0 ui_run "Image einspielen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' -y image load --file '$lf'" load_env "${ENV_FILE:-$PROJECT_DIR/.env}" return 0 ;; esac ui_run "Image holen ($sel)" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' -y image get --source '$sel'" load_env "${ENV_FILE:-$PROJECT_DIR/.env}" local f; f="$(mktemp)" NO_COLOR=1 cmd_image status >"$f" 2>&1 || true ui_text "Stand nach dem Holen" "$f"; rm -f "$f" } gui_image_versions() { local ed ed="$(gui_image_pick_edition)" || return 0 local f; f="$(mktemp)" ui_info "Versionen abrufen" "Frage Versionsarchiv, Registry und Downloadportal ab ...\n\nDas kann einen Moment dauern." NO_COLOR=1 cmd_image versions --edition "$ed" --limit 30 >"$f" 2>&1 || true ui_text "Verfuegbare Versionen - $ed" "$f" rm -f "$f" } gui_image_test() { local ed ver user pass ed="$(gui_image_pick_edition)" || return 0 ver="$(ui_input "Version" "Version (fuer die Pruefung des Downloadportals):" "${CMK_VERSION:-}")" || return 0 local -a extra=() if ui_yesno "Zugangsdaten" "Zugangsdaten aus der .env verwenden?\n\nRegistry : ${CMK_REGISTRY_USER:-}\nDownload : ${CMK_DOWNLOAD_USER:-}\n\n = jetzt eingeben"; then : else user="$(ui_input "Zugangsdaten" "Benutzer (Checkmk-Kundenportal):" "${CMK_REGISTRY_USER:-}")" || return 0 pass="$(ui_password "Zugangsdaten" "Kennwort:")" || return 0 [ -n "$user" ] && extra+=(--user "$user" --password "$pass") fi ui_run "Zugangsdaten pruefen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' image test --edition '$ed' --version '$ver' ${extra[*]+${extra[*]}}" } gui_image_pull() { local ed ver user pass ed="$(gui_image_pick_edition)" || return 0 ver="$(gui_image_pick_version "$ed" "${CMK_VERSION:-}")" || return 0 [ -n "$ver" ] || return 0 local -a extra=() if [ "$ed" = enterprise ] || [ -n "${CMK_REGISTRY_USER:-}" ]; then user="$(ui_input "Registry-Anmeldung" "Benutzer (Checkmk-Kundenportal):" "${CMK_REGISTRY_USER:-}")" || return 0 pass="$(ui_password "Registry-Anmeldung" "Kennwort:")" || return 0 [ -n "$user" ] && extra+=(--user "$user" --password "$pass") fi ui_run "Image holen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y image pull --edition '$ed' --version '$ver' ${extra[*]+${extra[*]}}" load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_image_download() { local ed ver user pass ed="$(gui_image_pick_edition)" || return 0 ver="$(gui_image_pick_version "$ed" "${CMK_VERSION:-}")" || return 0 [ -n "$ver" ] || return 0 case "$ver" in *-latest|latest|*-daily) ui_msg "Nicht moeglich" "Fuer den Tarball-Download wird eine exakte Version gebraucht,\nz.B. 2.3.0p23 - '$ver' gibt es dort nicht." return 0 ;; esac user="$(ui_input "Downloadportal" "Benutzer:" "${CMK_DOWNLOAD_USER:-}")" || return 0 pass="$(ui_password "Downloadportal" "Kennwort:")" || return 0 ui_run "Image herunterladen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y image download --edition '$ed' --version '$ver' --user '$user' --password '$pass'" load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_image_use() { local -a items=() l while IFS= read -r l; do [ -n "$l" ] || continue items+=("$l" "$([ "$l" = "${CMK_IMAGE:-}" ] && echo '(aktuell)' || echo ' ')") done < <(image_local_list) [ ${#items[@]} -gt 0 ] || { ui_msg "Images" "Es ist kein Checkmk-Image lokal vorhanden.\n\nZuerst 'pull', 'download' oder 'load' verwenden."; return; } local sel sel="$(ui_menu "Image auswaehlen" "Lokal vorhandene Checkmk-Images:" "${items[@]}")" || return 0 ui_run "Image setzen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y image use '$sel'" load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } # --------------------------------------------------------------------- # Einstellungen im Menue # --------------------------------------------------------------------- settings_needs_restart() { case "$1" in CMK_IMAGE|CMK_SITE_ID|CMK_CONTAINER_NAME|CMK_HOSTNAME|CMK_PASSWORD|\ CMK_LIVESTATUS_TCP|MAIL_RELAY_HOST|TZ|NPM_IMAGE|NPM_CONTAINER_NAME|\ NPM_DISABLE_IPV6|CMK_DATA_DIR|NPM_DATA_DIR|NPM_LE_DIR) return 0 ;; *) return 1 ;; esac } gui_settings_edit() { local key="$1" def typ label choices opt cur new msg def="$(settings_def_for "$key")" [ -n "$def" ] || { ui_msg "Fehler" "Unbekannter Schluessel '$key'."; return; } typ="$(printf '%s' "$def" | cut -d'|' -f3)" label="$(printf '%s' "$def" | cut -d'|' -f4)" choices="$(printf '%s' "$def"| cut -d'|' -f5)" opt="$(printf '%s' "$def" | cut -d'|' -f6)" cur="$(settings_value "$key")" if [ "$typ" = info ]; then local sel2 lage if image_consistency_check >/dev/null; then lage="Edition, Version und Image passen zusammen." else lage="Achtung: verwendet wird ${CMK_IMAGE:-}, eingestellt sind aber ${CMK_EDITION:-?} / ${CMK_VERSION:-?}. Gestartet wird immer das Image, nicht die Vorgaben." fi # Was ergaebe sich aus den Vorgaben? Das gehoert in die Beschriftung, # damit die Richtung der beiden Wege klar ist. local abgeleitet="" if [ -n "${CMK_EDITION:-}" ] && [ -n "${CMK_VERSION:-}" ] \ && edition_known "${CMK_EDITION}" 2>/dev/null; then abgeleitet="$(edition_repo "$CMK_EDITION"):$CMK_VERSION" fi sel2="$(ui_menu "$key" \ "Aktuell: ${cur:-} $lage" \ wizard "Image beschaffen (Edition, Version, Bezugsweg)" \ setzen "Image aus den Vorgaben setzen${abgeleitet:+ -> $abgeleitet}" \ angleichen "Umgekehrt: Vorgaben aus dem Image ableiten" \ nichts "Nichts aendern")" || { load_env "${ENV_FILE:-$PROJECT_DIR/.env}"; return 0; } case "$sel2" in wizard) gui_image_wizard || true ;; setzen) if [ -z "$abgeleitet" ]; then ui_msg "Nicht moeglich" \ "Aus den Vorgaben laesst sich keine Referenz bilden. Edition: ${CMK_EDITION:-} Version: ${CMK_VERSION:-} Beides muss gesetzt sein - der Assistent fragt danach." elif ui_yesno "Image setzen" \ "Aus Edition und Version wird: $abgeleitet Heruntergeladen wird nichts - das holt die Runtime beim naechsten Start selbst nach. Uebernehmen?"; then ui_run "Image setzen" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image use --edition '$CMK_EDITION' --version '$CMK_VERSION'" fi ;; angleichen) if [ -z "${CMK_IMAGE:-}" ]; then ui_msg "Nicht moeglich" "CMK_IMAGE ist nicht gesetzt." else ui_run "Vorgaben angleichen" bash -c \ "cd '$PROJECT_DIR' && '$SELF' -y image use '$CMK_IMAGE' --remember" fi ;; esac load_env "${ENV_FILE:-$PROJECT_DIR/.env}" return 0 fi while :; do case "$typ" in choice) local -a ropts=() c [ -z "$cur" ] && case ":$choices:" in *:auto:*) cur=auto ;; esac local IFS_BAK="$IFS"; IFS=':' for c in $choices; do IFS="$IFS_BAK" ropts+=("$c" " " "$([ "$c" = "$cur" ] && echo on || echo off)") IFS=':' done IFS="$IFS_BAK" new="$(ui_radiolist "$key" "$label\n\nAktuell: ${cur:-}" "${ropts[@]}")" || return 0 if [ "$new" = auto ]; then new=""; fi ;; password) new="$(ui_password "$key" "$label\n\n(leer lassen = unveraendert)")" || return 0 [ -z "$new" ] && return ;; *) new="$(ui_input "$key" "$label\n\nTyp: $typ$([ -n "$opt" ] && echo ' (darf leer bleiben)')" "$cur")" || return 0 ;; esac if msg="$(settings_validate "$typ" "$new" "$opt")" \ && msg="$(settings_validate_key "$key" "$new")"; then break else ui_msg "Ungueltige Eingabe" "$msg" cur="$new" fi done if [ "$key" = NPM_ADMIN_PORT ]; then ui_run "Admin-Port setzen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y npm-port '$new'" elif [ "$key" = NPM_ENABLED ]; then ui_run "Proxy Manager ${new}schalten" bash -c "cd '$PROJECT_DIR' && '$SELF' -y npm-enable '$new'" else env_set "$key" "$new" "$opt" local hint="" settings_needs_restart "$key" && hint="\n\nWirksam nach 'Stack steuern -> Stack starten'\n(die Container werden dabei neu erstellt)." ui_msg "Gespeichert" "$key = $([ "$typ" = password ] && echo '********' || echo "${new:-}")$hint" # Zugangsdaten lassen sich sofort pruefen - das erspart den Umweg case "$key" in CMK_REGISTRY_USER|CMK_REGISTRY_PASSWORD|CMK_DOWNLOAD_USER|CMK_DOWNLOAD_PASSWORD|CMK_REGISTRY|CMK_DOWNLOAD_BASEURL) load_env "${ENV_FILE:-$PROJECT_DIR/.env}" if ui_yesno "Zugangsdaten pruefen" \ "Sollen die Zugangsdaten jetzt geprueft werden? Getestet wird die Anmeldung an Registry und Downloadportal - es wird nichts heruntergeladen."; then ui_run "Zugangsdaten pruefen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' image test" fi ;; esac fi load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_settings_category() { local cat="$1" title="$2" while :; do local -a items=() c k t l ch o while IFS='|' read -r c k t l ch o; do [ "$c" = "$cat" ] || continue items+=("$k" "$l = $(settings_display "$k" "$t")") done < <(settings_defs) # Zusatzaktionen, die inhaltlich zur Kategorie gehoeren case "$cat" in image) items+=("__get__" ">> Image jetzt holen (Registry oder Downloadportal)") items+=("__wizard__" ">> Assistent: Edition, Version, Bezugsweg") items+=("__test__" ">> Zugangsdaten jetzt pruefen (Registry + Downloadportal)") ;; npm) items+=("__proxy__" ">> Proxy-Hosts anlegen/verwalten") items+=("__npm__" ">> Proxy Manager: Stand und Admin-Port anzeigen") items+=("__npmrst__" ">> Datenbank zuruecksetzen (Konto neu anlegen lassen)") ;; backup) items+=("__jobs__" ">> Backup-Jobs verwalten") ;; firewall) items+=("__fwcheck__" ">> Firewall jetzt pruefen") items+=("__fwapply__" ">> Firewall-Regeln uebernehmen (Ports oeffnen)") items+=("__fwclose__" ">> Nicht mehr benoetigte Ports schliessen") ;; esac items+=("back" "zurueck") local sel sel="$(ui_menu "$title" "Eintrag waehlen und mit ENTER aendern:" "${items[@]}")" || return 0 case "$sel" in back) return 0 ;; __get__) gui_image_get || true; continue ;; __wizard__) gui_image_wizard || true; continue ;; __proxy__) gui_proxy_menu || true; continue ;; __npmrst__) gui_npm_reset || true; continue ;; __test__) gui_image_test || true; continue ;; __jobs__) gui_jobs_menu || true; continue ;; __fwcheck__) gui_firewall || true; continue ;; __fwapply__) gui_firewall_apply || true; continue ;; __fwclose__) gui_firewall_close || true; continue ;; __npm__) local f; f="$(mktemp)" { NO_COLOR=1 cmd_npm_enable; NO_COLOR=1 cmd_npm_port; } >"$f" 2>&1 || true ui_text "NGINX Proxy Manager" "$f"; rm -f "$f"; continue ;; esac gui_settings_edit "$sel" done } gui_settings_menu() { while :; do local -a items=() c l while IFS='|' read -r c l; do [ -n "$c" ] && items+=("$c" "$l") done < <(settings_categories) items+=("jobs" "Backup-Jobs (Zeitplan, Zielpfade, Aufbewahrung)") items+=("show" "Alle Werte auflisten") items+=("editor" "Datei .env im Editor oeffnen") items+=("back" "zurueck") local sel sel="$(ui_menu "Einstellungen" \ "Alle Werte der Datei .env - direkt hier aenderbar. Datei: ${ENV_FILE:-$PROJECT_DIR/.env}" "${items[@]}")" || return 0 case "$sel" in back) return 0 ;; jobs) gui_jobs_menu || true ;; show) local f; f="$(mktemp)" { printf '%-24s %-10s %s\n' "SCHLUESSEL" "BEREICH" "WERT" NO_COLOR=1 cmd_config list; } >"$f" 2>&1 ui_text "Alle Einstellungen" "$f"; rm -f "$f" ;; editor) local ef="${ENV_FILE:-$PROJECT_DIR/.env}" if [ ! -f "$ef" ]; then ui_msg "Keine .env" "Die Datei $ef existiert nicht.\n\nAnlegen mit: cp .env.example .env"; continue; fi clear 2>/dev/null || true; "${EDITOR:-nano}" "$ef" load_env "$ef"; ui_msg "Konfiguration" "Die .env wurde neu eingelesen." ;; *) local title title="$(settings_categories | awk -F'|' -v c="$sel" '$1==c{print $2}')" gui_settings_category "$sel" "${title:-$sel}" ;; esac done } gui_browser_menu() { while :; do local sel sel="$(ui_menu "Terminal-Browser" \ "Bedienung des NGINX Proxy Managers direkt auf dieser Maschine. Startseite: ${NPM_ADMIN_URL:-http://127.0.0.1:81} Nur browsh und carbonyl koennen JavaScript und damit die Oberflaeche des Proxy Managers darstellen." \ browsh "Firefox im Terminal (empfohlen)" \ carbonyl "Chromium im Terminal" \ links "links2 - Textbrowser, KEIN JavaScript" \ w3m "w3m - Textbrowser, KEIN JavaScript" \ lynx "lynx - Textbrowser, KEIN JavaScript" \ shell "Shell mit curl/jq/openssl im Host-Netzwerk" \ url "Andere Startseite eingeben" \ build "Werkzeug-Image neu bauen" \ back "zurueck")" || return 0 case "$sel" in url) local u u="$(ui_input "Startseite" "URL:" "${NPM_ADMIN_URL:-http://127.0.0.1:81}")" || continue if [ -n "$u" ]; then NPM_ADMIN_URL="$u"; fi ;; build) ui_run "Werkzeug-Image bauen" bash -c "cd '$PROJECT_DIR' && '$SELF' browser --build-only" || true ;; back) return 0 ;; *) local burl="${NPM_ADMIN_URL:-http://127.0.0.1:81}" if ! browser_url_reachable "$burl"; then if ui_yesno "Startseite antwortet nicht" \ "$(browser_diagnose_url "$burl") Trotzdem starten? kehrt ins Menue zurueck - dort laesst sich unter 'Andere Startseite eingeben' auch eine andere Adresse waehlen."; then gui_run_interactive cmd_browser --engine "$sel" --url "$burl" --no-check fi continue fi gui_run_interactive cmd_browser --engine "$sel" --url "$burl" ;; esac done } # --------------------------------------------------------------------- # Proxy-Hosts im Menue # --------------------------------------------------------------------- # Datenbank des Proxy Managers zuruecksetzen, damit der Container das # Konto beim naechsten Start neu aus NPM_ADMIN_EMAIL/-PASSWORD anlegt. gui_npm_reset() { local d="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}" case "$d" in /*) : ;; *) d="$PROJECT_DIR/${d#./}" ;; esac if ! npm_db_exists; then ui_msg "Nichts zurueckzusetzen" \ "Der Proxy Manager hat noch keine Datenbank. Es genuegt, die Zugangsdaten zu setzen und den Stack zu starten - der Container legt das Konto daraus selbst an: Einstellungen -> NGINX Proxy Manager NPM_ADMIN_EMAIL NPM_ADMIN_PASSWORD danach: Stack steuern -> Stack starten" return 0 fi # Ohne Zugangsdaten entstuende beim naechsten Start wieder kein Konto if [ -z "${NPM_ADMIN_EMAIL:-}" ] || [ -z "${NPM_ADMIN_PASSWORD:-}" ]; then if ui_yesno "Zugangsdaten fehlen" \ "NPM_ADMIN_EMAIL bzw. NPM_ADMIN_PASSWORD sind nicht gesetzt. Ohne sie entsteht beim naechsten Start wieder KEIN Konto - der Proxy Manager zeigt dann erneut den Einrichtungsassistenten. Jetzt eintragen?"; then gui_settings_edit NPM_ADMIN_EMAIL gui_settings_edit NPM_ADMIN_PASSWORD load_env "${ENV_FILE:-$PROJECT_DIR/.env}" fi if [ -z "${NPM_ADMIN_EMAIL:-}" ] || [ -z "${NPM_ADMIN_PASSWORD:-}" ]; then ui_msg "Abgebrochen" "Ohne Zugangsdaten hat das Zuruecksetzen keinen Zweck." return 0 fi fi local groesse groesse="$(du -h "$d/database.sqlite" 2>/dev/null | cut -f1 || echo '?')" ui_yesno "Datenbank zuruecksetzen" \ "Die Datenbank wird beiseitegelegt - umbenannt, nicht geloescht: $d/database.sqlite ($groesse) ${C_BLD}Das ist danach weg${C_RST} Konten, Proxy-Hosts, Weiterleitungen, Zugriffslisten ${C_BLD}Das bleibt${C_RST} Zertifikate (Let's Encrypt und eigene) Beim naechsten Start legt der Container das Konto neu an: ${NPM_ADMIN_EMAIL} Proxy-Hosts lassen sich anschliessend unter 'Proxy-Hosts' in einem Durchgang neu anlegen. Fortfahren?" || return 0 ui_run "Datenbank zuruecksetzen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' -y npm-reset" if ui_yesno "Jetzt starten?" \ "Damit das Konto entsteht, muss der Proxy Manager neu starten. Stack jetzt starten?"; then ui_run "Stack starten" bash -c "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' -y up" fi load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_proxy_menu() { while :; do local sel sel="$(ui_menu "Proxy-Hosts" \ "NGINX Proxy Manager ueber seine Schnittstelle bedienen - das ist verlaesslicher als der Terminal-Browser. Konto: ${NPM_ADMIN_EMAIL:-} Ziel : ${PROXY_FORWARD_HOST:-127.0.0.1}:${PROXY_FORWARD_PORT:-5000}" \ anlegen "Proxy-Host anlegen (Assistent)" \ list "Vorhandene Proxy-Hosts anzeigen" \ edit "Proxy-Host bearbeiten (Ziel, Pfade, Zertifikat ...)" \ show "Einzelheiten eines Proxy-Hosts (mit Pfaden)" \ certs "Vorhandene Zertifikate anzeigen" \ ipzugang "HTTPS ueber die blanke IP (ohne Namen) ermoeglichen" \ del "Proxy-Host loeschen" \ status "Anmeldung pruefen" \ reset "Datenbank zuruecksetzen (Konto neu anlegen lassen)" \ back "zurueck")" || return 0 case "$sel" in anlegen) gui_proxy_add || true ;; edit) gui_proxy_edit || true ;; list) local f; f="$(mktemp)"; NO_COLOR=1 cmd_proxy list >"$f" 2>&1 || true ui_text "Proxy-Hosts" "$f"; rm -f "$f" ;; show) local d3; d3="$(ui_input "Einzelheiten" "Name oder ID des Proxy-Hosts:" "${PROXY_DOMAIN:-}")" || continue [ -n "$d3" ] || continue local f3; f3="$(mktemp)" NO_COLOR=1 cmd_proxy show --domain "$d3" >"$f3" 2>&1 || true ui_text "Proxy-Host $d3" "$f3"; rm -f "$f3" ;; certs) local f2; f2="$(mktemp)"; NO_COLOR=1 cmd_proxy certs >"$f2" 2>&1 || true ui_text "Zertifikate" "$f2"; rm -f "$f2" ;; ipzugang) gui_proxy_default || true ;; del) gui_proxy_del || true ;; status) ui_run "Anmeldung pruefen" bash -c "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' proxy status" ;; reset) gui_npm_reset || true ;; back) return 0 ;; esac done } # Proxy-Host auswaehlen -> ID auf stdout gui_pick_proxy() { # titel if ! npm_require_running >/dev/null 2>&1; then ui_msg "Proxy Manager" "Der Proxy Manager laeuft nicht oder ist abgeschaltet." >&2 return 1 fi NPM_TOKEN="$(npm_api_token 2>/dev/null)" || { ui_msg "Proxy Manager" "Anmeldung fehlgeschlagen. Zugangsdaten pruefen unter Einstellungen > NGINX Proxy Manager, oder die Datenbank zuruecksetzen." >&2 return 1 } local all i o dom local -a items=() all="$(npm_api GET /api/nginx/proxy-hosts)" for i in $(json_ids "$all"); do o="$(npm_api GET "/api/nginx/proxy-hosts/$i")" dom="$(printf '%s' "$o" | sed -n 's/.*"domain_names"[[:space:]]*:[[:space:]]*\[[[:space:]]*"\([^"]*\)".*/\1/p')" items+=("$i" "${dom:-?} -> $(json_str "$o" forward_host):$(json_num "$o" forward_port)") done if [ "${#items[@]}" -eq 0 ]; then ui_msg "Proxy-Hosts" "Es gibt noch keinen Proxy-Host. Anlegen unter: Proxy-Host anlegen (Assistent)" >&2 return 1 fi ui_menu "${1:-Proxy-Host waehlen}" "Welchen Eintrag?" "${items[@]}" } # Einen vorhandenen Proxy-Host bearbeiten gui_proxy_edit() { local pid pid="$(gui_pick_proxy "Proxy-Host bearbeiten")" || return 0 [ -n "$pid" ] || return 0 # gui_pick_proxy lief in einer Kommandoersetzung, sein Token ist mit der # Subshell verschwunden - hier neu holen. NPM_TOKEN="$(npm_api_token)" || { ui_msg "Proxy Manager" "Anmeldung fehlgeschlagen."; return 0; } while :; do local o dom o="$(npm_api GET "/api/nginx/proxy-hosts/$pid")" if [ -z "$(json_num "$o" id)" ]; then ui_msg "Proxy-Host" "Der Eintrag $pid ist nicht mehr da."; return 0 fi dom="$(printf '%s' "$o" | sed -n 's/.*"domain_names"[[:space:]]*:[[:space:]]*\[[[:space:]]*"\([^"]*\)".*/\1/p')" local sel sel="$(ui_menu "Proxy-Host $pid bearbeiten" \ "Name : $dom Ziel : $(json_str "$o" forward_scheme)://$(json_str "$o" forward_host):$(json_num "$o" forward_port) Zertifikat : $(json_num "$o" certificate_id) Aktiv: $(json_raw "$o" enabled) Jede Aenderung wirkt sofort; alles Uebrige bleibt unangetastet." \ ziel "Weiterleitungsziel (Host und Port)" \ schema "Schema zum Ziel (http/https)" \ name "Domainname aendern" \ cert "Zertifikat zuweisen oder entfernen" \ pfad "Pfad hinzufuegen oder ersetzen" \ pfadweg "Pfad entfernen" \ schalter "Schalter (HTTPS erzwingen, HTTP/2, WebSockets, Sperre)" \ anaus "Proxy-Host ein- oder ausschalten" \ adv "Erweiterte nginx-Konfiguration" \ zeigen "Aktuellen Stand anzeigen" \ back "zurueck")" || return 0 local -a args=() case "$sel" in back) return 0 ;; zeigen) local f; f="$(mktemp)" NO_COLOR=1 cmd_proxy show --id "$pid" >"$f" 2>&1 || true ui_text "Proxy-Host $pid" "$f"; rm -f "$f"; continue ;; ziel) local h pt h="$(ui_input "Weiterleitungsziel" "Host:" "$(json_str "$o" forward_host)")" || continue [ -n "$h" ] || continue pt="$(ui_input "Weiterleitungsziel" "Port:" "$(json_num "$o" forward_port)")" || continue [ -n "$pt" ] || continue args=(--forward-host "$h" --forward-port "$pt") ;; schema) local sc sc="$(ui_menu "Schema zum Ziel" "Wie spricht der Proxy das Ziel an?" \ http "unverschluesselt (der Regelfall im eigenen Netz)" \ https "verschluesselt")" || continue args=(--scheme "$sc") ;; name) local nn nn="$(ui_input "Domainname" "Neuer Name:" "$dom")" || continue [ -n "$nn" ] || continue args=(--rename "$nn") ;; cert) local cc cc="$(ui_menu "Zertifikat" "Was soll ausgeliefert werden?" \ self "selbstsigniert (fuer das eigene Netz)" \ letsencrypt "Lets Encrypt (Server muss oeffentlich erreichbar sein)" \ none "keines - nur HTTP")" || continue args=(--cert "$cc") ;; pfad) local sp sp="$(ui_input "Pfad" "Pfad und Ziel, z.B. /grafana=127.0.0.1:3000" "")" || continue [ -n "$sp" ] || continue args=(--location "$sp") ;; pfadweg) local el local -a pitems=() while IFS= read -r el; do [ -n "$el" ] || continue pitems+=("$(json_str "$el" path)" "-> $(json_str "$el" forward_host):$(json_num "$el" forward_port)") done < <(json_split_array "$(json_raw "$o" locations)") if [ "${#pitems[@]}" -eq 0 ]; then ui_msg "Pfade" "Dieser Proxy-Host hat keine zusaetzlichen Pfade."; continue fi local dp dp="$(ui_menu "Pfad entfernen" "Welchen Pfad?" "${pitems[@]}")" || continue ui_yesno "Pfad entfernen" "Pfad $dp wirklich entfernen?" || continue args=(--del-location "$dp") ;; schalter) local cur cur="$(ui_checklist "Schalter" "Mit der Leertaste umschalten:" \ force-ssl "HTTP auf HTTPS umleiten" "$(json_bool_on "$o" ssl_forced)" \ http2 "HTTP/2" "$(json_bool_on "$o" http2_support)" \ websockets "WebSockets durchreichen" "$(json_bool_on "$o" allow_websocket_upgrade)" \ block-exploits "Bekannte Angriffsmuster sperren" "$(json_bool_on "$o" block_exploits)")" || continue local o1 o2 o3 o4 case " $cur " in *" force-ssl "*|*'"force-ssl"'*) o1=on ;; *) o1=off ;; esac case " $cur " in *" http2 "*|*'"http2"'*) o2=on ;; *) o2=off ;; esac case " $cur " in *" websockets "*|*'"websockets"'*) o3=on ;; *) o3=off ;; esac case " $cur " in *" block-exploits "*|*'"block-exploits"'*) o4=on ;; *) o4=off ;; esac args=(--force-ssl "$o1" --http2 "$o2" --websockets "$o3" --block-exploits "$o4") ;; anaus) if [ "$(json_bool_on "$o" enabled)" = on ]; then ui_yesno "Ausschalten" "Proxy-Host $pid ausschalten?" || continue args=(--disable) else ui_yesno "Einschalten" "Proxy-Host $pid einschalten?" || continue args=(--enable) fi ;; adv) local a1 curadv curadv="$(json_raw "$o" advanced_config)" curadv="${curadv#\"}"; curadv="${curadv%\"}" a1="$(ui_input "Erweiterte Konfiguration" \ "nginx-Anweisungen fuer diesen Proxy-Host. Mehrere Anweisungen durch \\\\n trennen." "$curadv")" || continue args=(--advanced "$a1") ;; esac [ "${#args[@]}" -gt 0 ] || continue local f2 rc=0 f2="$(mktemp)" NO_COLOR=1 cmd_proxy edit --id "$pid" "${args[@]}" >"$f2" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then ui_text "Proxy-Host $pid geaendert" "$f2" else ui_text "Aenderung fehlgeschlagen" "$f2" fi rm -f "$f2" done } gui_proxy_default() { local f rc=0 f="$(mktemp)" NO_COLOR=1 cmd_proxy default >"$f" 2>&1 || true ui_text "HTTPS ueber die blanke IP" "$f"; rm -f "$f" local sel sel="$(ui_menu "HTTPS ueber die blanke IP" \ "Ruft man den Server als https:/// auf, schickt der Browser keinen Namen mit - SNI kennt keine IP-Adressen. nginx findet den Proxy-Host dann nicht und lehnt den Handshake ab (ERR_SSL_UNRECOGNIZED_NAME_ALERT). Ueber http:/// faellt das nicht auf, dort zaehlt der Host-Kopf. Ein Standard-Server nimmt genau diese Aufrufe an und geht denselben Weg wie der gewaehlte Proxy-Host, samt seiner zusaetzlichen Pfade. Aufrufe mit Namen bleiben unberuehrt." \ ein "Einrichten - Proxy-Host spiegeln" \ aus "Entfernen" \ back "zurueck")" || return 0 case "$sel" in back) return 0 ;; aus) ui_run "Standard-Server entfernen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' proxy default --off --restart" || true return 0 ;; esac local dom dom="$(ui_input "Einrichten" "Name des Proxy-Hosts, der gespiegelt werden soll:" \ "${PROXY_DOMAIN:-}")" || return 0 [ -n "$dom" ] || return 0 f="$(mktemp)" NO_COLOR=1 cmd_proxy default --domain "$dom" --restart >"$f" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then ui_text "Standard-Server eingerichtet" "$f" else ui_text "Einrichten fehlgeschlagen" "$f" fi rm -f "$f" return 0 } gui_proxy_add() { if ! npm_require_running 2>/dev/null; then ui_msg "Proxy Manager laeuft nicht" \ "Der NGINX Proxy Manager muss laufen, damit sich ein Proxy-Host anlegen laesst. Abgeschaltet? Einstellungen -> NGINX Proxy Manager Nicht gestartet? Stack steuern -> Stack starten" return 0 fi if [ -z "${NPM_ADMIN_EMAIL:-}" ] || [ -z "${NPM_ADMIN_PASSWORD:-}" ]; then ui_msg "Zugangsdaten fehlen" \ "Fuer die Schnittstelle werden die Zugangsdaten des Proxy Managers gebraucht: NPM_ADMIN_EMAIL und NPM_ADMIN_PASSWORD. Sie stehen unter Einstellungen -> NGINX Proxy Manager. Hinweis: Seit Version 2.15 legt der Container das Konto nur noch beim ERSTEN Start an, und nur wenn beide Werte gesetzt sind. Wurde das Konto im Browser angelegt, hier dessen Daten eintragen." return 0 fi local dom fport cert dom="$(ui_input "Proxy-Host (1/3)" \ "Unter welchem Namen soll Checkmk erreichbar sein? Der Name muss auf diesen Host zeigen - per DNS oder per hosts-Datei der Arbeitsplatzrechner." "${PROXY_DOMAIN:-}")" || return 0 [ -n "$dom" ] || return 0 fport="$(ui_input "Proxy-Host (2/3)" \ "Auf welchen Port soll weitergeleitet werden? 5000 = Checkmk-Oberflaeche (Vorgabe) 81 = Oberflaeche des Proxy Managers selbst" "${PROXY_FORWARD_PORT:-5000}")" || return 0 valid_port "$fport" || { ui_msg "Ungueltig" "'$fport' ist keine gueltige Portnummer."; return 0; } cert="$(ui_menu "Proxy-Host (3/3) - Zertifikat" \ "$dom -> 127.0.0.1:$fport Womit soll HTTPS abgesichert werden?" \ self "Selbstsigniert (intern, sofort einsatzbereit)" \ letsencrypt "Let's Encrypt (Port 80 muss aus dem Internet erreichbar sein)" \ none "Ohne HTTPS (nur Port 80)")" || return 0 # --- weitere Dienste unter demselben Namen, nach Pfad getrennt ---- local -a locopt=() local loclist="" if ui_yesno "Weitere Dienste?" \ "Sollen unter demselben Namen noch andere Dienste erreichbar sein, unterschieden nach dem Pfad? https://$dom/ -> Checkmk (das Ziel von eben) https://$dom/grafana -> Grafana https://$dom/sonstwas -> ein weiterer Dienst Das ist der Weg, wenn nur EIN Port nach aussen frei ist."; then while :; do local lpath ltarget lpath="$(ui_input "Pfad" \ "Unter welchem Pfad soll der Dienst liegen? Leer lassen beendet die Eingabe." "")" || break [ -n "$lpath" ] || break ltarget="$(ui_input "Ziel fuer $lpath" \ "Wohin soll $lpath weitergeleitet werden? Schreibweise: host:port z.B. 127.0.0.1:3000 https://host:port fuer verschluesselte Ziele" "127.0.0.1:3000")" || break [ -n "$ltarget" ] || break if ! proxy_location_json "$lpath=$ltarget" >/dev/null 2>&1; then ui_msg "Ungueltig" "'$lpath=$ltarget' konnte nicht gelesen werden.\n\nErwartet: /pfad und host:port" continue fi locopt+=(--location "$lpath=$ltarget") loclist="$loclist $lpath -> $ltarget" ui_yesno "Noch einer?" "Bisher:$loclist Einen weiteren Pfad hinzufuegen?" || break done fi local upd="" if ui_yesno "Anlegen" \ "$dom -> 127.0.0.1:$fport Zertifikat: $cert${loclist:+ Zusaetzliche Pfade:$loclist} Einen bereits vorhandenen Eintrag fuer diesen Namen ersetzen? ersetzt ihn bricht ab, falls es ihn schon gibt"; then upd=--update; fi ui_run "Proxy-Host anlegen" bash -c \ "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' -y proxy add --domain '$dom' \ --forward-port '$fport' --cert '$cert' $upd ${locopt[*]+${locopt[*]}}" # Die Vorgaben merken, damit der naechste Aufruf sie kennt env_set PROXY_DOMAIN "$dom" 1 2>/dev/null || true env_set PROXY_FORWARD_PORT "$fport" 2>/dev/null || true load_env "${ENV_FILE:-$PROJECT_DIR/.env}" } gui_proxy_del() { local f; f="$(mktemp)" NO_COLOR=1 cmd_proxy list >"$f" 2>&1 || true ui_text "Vorhandene Proxy-Hosts" "$f"; rm -f "$f" local id id="$(ui_input "Loeschen" "ID des zu loeschenden Proxy-Hosts:" "")" || return 0 [ -n "$id" ] || return 0 case "$id" in ''|*[!0-9]*) ui_msg "Ungueltig" "Bitte eine Zahl angeben."; return 0 ;; esac ui_yesno "Loeschen" "Proxy-Host $id wirklich loeschen?" || return 0 ui_run "Proxy-Host loeschen" bash -c "cd '$PROJECT_DIR' && '$SELF' -y proxy del --id '$id'" } gui_cert() { local domain alt days domain="$(ui_input "Zertifikat" "FQDN / Common Name:" "${CERT_DOMAIN:-checkmk.example.local}")" || return 0 [ -n "$domain" ] || return 0 alt="$(ui_input "Zertifikat" "Weitere Namen/IPs (durch Leerzeichen getrennt, optional):" "$(primary_ip)")" || alt="" days="$(ui_input "Zertifikat" "Gueltigkeit in Tagen:" "${CERT_DAYS:-3650}")" || days=3650 local -a a=(); local x for x in $alt; do a+=(--alt "$x"); done ui_run "Zertifikat erzeugen" bash -c "cd '$PROJECT_DIR' && '$SELF' cert --domain '$domain' --days '$days' ${a[*]+${a[*]}}" } # Bericht anzeigen; fehlt etwas, das Uebernehmen anbieten gui_firewall() { local tmp rc=0 tmp="$(mktemp)" cmd_firewall check >"$tmp" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then ui_text "Firewall - alles offen, was gebraucht wird" "$tmp" rm -f "$tmp"; return 0 fi ui_text "Firewall - es fehlen Freigaben" "$tmp" rm -f "$tmp" ui_yesno "Firewall" "Es fehlen Freigaben. Die benoetigten Ports jetzt oeffnen?" || return 0 gui_firewall_apply } # Regeln uebernehmen. Erst wird gefragt, ob auf ein Quellnetz begrenzt # werden soll - denn davon haengen die Kommandos ab, die danach zur # Bestaetigung angezeigt werden. Was dort steht, wird auch ausgefuehrt. gui_firewall_apply() { local src="" tmp rc=0 if ui_yesno "Firewall-Regeln uebernehmen" "Nur fuer ein bestimmtes Quellnetz freigeben? Ja = nur dieses Netz darf zugreifen Nein = Freigabe fuer alle Quellen"; then src="$(ui_input "Firewall-Regeln uebernehmen" "Quellnetz in CIDR-Schreibweise:" \ "$(primary_ip | sed 's/\.[0-9]*$/.0\/24/')")" || return 0 [ -n "$src" ] || return 0 fi local -a opt=(open) if [ -n "$src" ]; then opt+=(--source "$src"); fi tmp="$(mktemp)" cmd_firewall "${opt[@]}" >"$tmp" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then ui_text "Firewall - nichts zu tun" "$tmp" rm -f "$tmp"; return 0 fi ui_text "Firewall - diese Regeln werden gesetzt" "$tmp" rm -f "$tmp" ui_yesno "Firewall-Regeln uebernehmen" "Die angezeigten Regeln jetzt dauerhaft setzen?" || return 0 local -a a=(firewall "${opt[@]}" --apply --yes) if [ "$(id -u)" -eq 0 ]; then ui_run "Firewall-Regeln uebernehmen" "$SELF" "${a[@]}" || true elif command -v sudo >/dev/null 2>&1; then gui_run_interactive sudo -- "$SELF" "${a[@]}" else ui_msg "Firewall" "Dafuer werden root-Rechte benoetigt. Bitte als root erneut aufrufen." return 0 fi # Zeigen, was dabei herausgekommen ist tmp="$(mktemp)" cmd_firewall check >"$tmp" 2>&1 || true ui_text "Firewall - Stand danach" "$tmp" rm -f "$tmp" return 0 } # Nicht mehr benoetigte Ports wieder schliessen gui_firewall_close() { local tmp rc=0 hat="" tmp="$(mktemp)" cmd_firewall close >"$tmp" 2>&1 || rc=$? if ( set +o pipefail; grep -q 'Zum Schliessen' "$tmp" ); then hat=1; fi ui_text "Nicht mehr benoetigte Ports" "$tmp" rm -f "$tmp" [ -n "$hat" ] || return 0 ui_yesno "Ports schliessen" "Die angezeigten Ports jetzt schliessen? SSH bleibt in jedem Fall offen - Port 22, der Port von sshd und der Port dieser Sitzung. Freigaben fuer fremde Dienste werden nicht angeruehrt." || return 0 local -a a=(firewall close --apply --yes) if [ "$(id -u)" -eq 0 ]; then ui_run "Ports schliessen" "$SELF" "${a[@]}" || true elif command -v sudo >/dev/null 2>&1; then gui_run_interactive sudo -- "$SELF" "${a[@]}" else ui_msg "Firewall" "Dafuer werden root-Rechte benoetigt. Bitte als root erneut aufrufen." return 0 fi tmp="$(mktemp)" cmd_firewall check >"$tmp" 2>&1 || true ui_text "Firewall - Stand danach" "$tmp" rm -f "$tmp" return 0 } gui_main() { ui_require while :; do local sel sel="$(ui_menu "Checkmk Stack Manager" \ "Projekt : $PROJECT_DIR Runtime : $RUNTIME Standard-Site: $CMK_SITE_ID Backups : $BACKUP_DIR" \ status "Status von Containern und Sites" \ stack "Stack steuern (Start/Stop/Logs/Update)" \ backup "Backup erstellen / planen" \ restore "Backup wiederherstellen" \ import "Site von Direktinstallation importieren" \ manage "Backups verwalten (Liste/Details/Loeschen)" \ sites "Sites im Container verwalten" \ image "Checkmk-Image beschaffen/auswaehlen" \ proxy "Proxy-Hosts anlegen/verwalten (HTTPS fuer Checkmk)" \ browser "Terminal-Browser (Proxy Manager lokal bedienen)" \ cert "Selbstsigniertes Zertifikat erzeugen" \ firewall "Firewall pruefen (welche Ports muessen offen sein)" \ config "Einstellungen (.env) - menuegefuehrt" \ shell "Root-Shell im Checkmk-Container" \ quit "Beenden")" || break case "$sel" in status) gui_status || true ;; stack) gui_stack_menu || true ;; backup) gui_backup_menu || true ;; restore) gui_restore || true ;; import) gui_import || true ;; manage) gui_backups_menu || true ;; sites) gui_sites_menu || true ;; image) gui_image_menu || true ;; proxy) gui_proxy_menu || true ;; browser) gui_browser_menu || true ;; cert) gui_cert || true ;; firewall) gui_firewall || true ;; config) gui_settings_menu || true ;; shell) gui_need_container || continue gui_run_interactive cmd_shell ;; quit) break ;; esac done clear 2>/dev/null || true ok "Beendet." } # ===================================================================== # Terminal pruefen # # Zeigt zwei Testzeilen. Woran es liegt, laesst sich an ihnen ablesen - # ohne Raten: # Zeile A sind echte UTF-8-Rahmenzeichen (U+2500 ff.) # Zeile B ist die alte VT100-Umschaltung ESC(0 mit den Buchstaben lqk # Genau diese Buchstaben erscheinen, wenn das Terminal die Umschaltung # nicht mitmacht - der Fall bei PuTTY im UTF-8-Modus. # ===================================================================== cmd_tui_check() { ui_fix_locale || true ui_init printf '\n%sWas dieses Terminal meldet%s\n' "$C_BLD" "$C_RST" printf ' TERM : %s\n' "${TERM:-}" printf ' LANG : %s\n' "${LANG:-}" printf ' LC_ALL : %s\n' "${LC_ALL:-}" printf ' LC_CTYPE : %s\n' "${LC_CTYPE:-}" printf ' Zeichensatz : %s\n' "$(locale charmap 2>/dev/null || echo unbekannt)" printf ' Programm : %s\n' "${UI_BIN:-keines (dialog/whiptail fehlt)}" printf ' Unicode-ACS : %s\n' \ "$([ -n "${NCURSES_NO_UTF8_ACS:-}" ] && echo 'ein (ncurses zeichnet Unicode)' || echo 'aus (ncurses nutzt ESC(0)')" [ -n "${UI_LOCALE_NOTE:-}" ] && printf ' Angepasst : %s\n' "$UI_LOCALE_NOTE" printf '\n%sZeile A - echte UTF-8-Rahmenzeichen%s\n' "$C_BLD" "$C_RST" printf ' ┌────────┐\n' printf ' │ Rahmen │\n' printf ' └────────┘\n' printf '\n%sZeile B - alte VT100-Umschaltung (ESC(0)%s\n' "$C_BLD" "$C_RST" printf ' \033(0lqqqqqqqqk\033(B\n' printf ' \033(0x\033(B Rahmen \033(0x\033(B\n' printf ' \033(0mqqqqqqqqj\033(B\n' cat < Translation -> Remote character set: UTF-8 Window -> Translation -> Handling of line drawing characters: "Use Unicode line drawing code points" Connection -> Data -> Terminal-type string: xterm-256color Auf dieser Seite hilft zusaetzlich 'dialog' statt 'whiptail': dnf install -y dialog bzw. apt-get install -y dialog Oder einfache Rahmen erzwingen: CMK_TUI_ASCII=1 $SELF gui A zeigt Kaese wie a-- oder Fragezeichen Das Terminal laeuft NICHT im UTF-8-Modus. In PuTTY: Window -> Translation -> Remote character set: UTF-8 Beide Zeilen zeigen Buchstaben Schrift ohne Rahmenzeichen. In PuTTY unter Window -> Appearance eine andere waehlen, z.B. Consolas oder DejaVu Sans Mono. EOF } # ===================================================================== # Firewall # # RHEL und Rocky haben firewalld ab Werk aktiv, und dort ist in aller # Regel nur SSH freigegeben. Der Agent-Receiver auf 8000 und HTTPS auf # 443 waeren damit dicht - die Agents kaemen nicht durch, ohne dass es # am Stack selbst laege. # # Geprueft werden firewalld, ufw, nftables und iptables. Welche Ports # noetig sind, haengt von der Konfiguration ab: ohne Proxy Manager # braucht der externe Proxy Zugriff auf 5000, als Remote-Site einer # verteilten Ueberwachung kommt Livestatus dazu, mit Event Console # Syslog und SNMP-Traps - und die laufen ueber UDP. Ports werden # deshalb durchweg als "nummer/protokoll" gefuehrt. # ===================================================================== fw_lc() { printf '%s' "${1:-}" | tr 'A-Z' 'a-z'; } fw_on() { # Wert -> 0, wenn eingeschaltet case "$(fw_lc "${1:-}")" in on|true|1|yes|ja) return 0 ;; esac return 1 } fw_detect() { if command -v firewall-cmd >/dev/null 2>&1 \ && firewall-cmd --state >/dev/null 2>&1; then printf 'firewalld\n'; return 0 fi if command -v ufw >/dev/null 2>&1 \ && ( set +o pipefail; ufw status 2>/dev/null | grep -qi '^Status: active' ); then printf 'ufw\n'; return 0 fi if command -v nft >/dev/null 2>&1 && [ -n "$(nft list ruleset 2>/dev/null)" ]; then printf 'nftables\n'; return 0 fi if command -v iptables >/dev/null 2>&1 \ && [ "$(iptables -S 2>/dev/null | wc -l)" -gt 3 ]; then printf 'iptables\n'; return 0 fi printf 'keine\n' } # firewalld kennt Zonen. Hat der Rechner mehrere Netzwerkkarten, haengen # sie oft in verschiedenen Zonen - dann sagt die Standardzone allein # nichts darueber aus, ob ein Port erreichbar ist. fw_zones() { if [ -n "${_FW_ZONES:-}" ]; then printf '%s\n' "$_FW_ZONES"; return 0; fi local z z="$(firewall-cmd --get-active-zones 2>/dev/null | grep -v '^[[:space:]]' || true)" if [ -z "$z" ]; then z="$(firewall-cmd --get-default-zone 2>/dev/null || true)"; fi _FW_ZONES="$(printf '%s\n' "$z" | sed '/^$/d')" printf '%s\n' "$_FW_ZONES" } # Dienstnamen von firewalld auf port/protokoll abbilden fw_service_port() { case "$1" in ssh) printf '22/tcp\n' ;; http) printf '80/tcp\n' ;; https) printf '443/tcp\n' ;; cockpit) printf '9090/tcp\n' ;; syslog) printf '514/udp\n514/tcp\n' ;; syslog-tls) printf '6514/tcp\n' ;; snmptrap) printf '162/udp\n' ;; snmp) printf '161/udp\n' ;; *) : ;; esac } # --------------------------------------------------------------------- # Welche Ports braucht der Stack in dieser Konfiguration? # port/proto|zweck|noetig (noetig: ja | nein | admin) # --------------------------------------------------------------------- fw_required_ports() { local role; role="$(fw_lc "${FW_ROLE:-single}")" case "$role" in central|remote) : ;; *) role=single ;; esac if npm_enabled; then printf '80/tcp|HTTP und Lets-Encrypt-Pruefung|ja\n' printf '443/tcp|HTTPS (Checkmk, Zentrale, weitere Dienste)|ja\n' printf '%s/tcp|Adminoberflaeche des Proxy Managers|admin\n' "${NPM_ADMIN_PORT:-81}" printf '5000/tcp|Checkmk direkt - gehoert hinter den Proxy|nein\n' else printf '5000/tcp|Checkmk direkt - Ziel des externen Proxys|ja\n' fi printf '8000/tcp|Agent-Receiver (registrierte Agents)|ja\n' # --- Verteilte Ueberwachung --------------------------------------- # Als Remote-Site fragt die Zentrale Livestatus ab. Die Zentrale # selbst baut nur Verbindungen auf und braucht dafuer nichts offen. if [ "$role" = remote ]; then printf '6557/tcp|Livestatus - die Zentrale fragt diese Site ab|ja\n' elif fw_on "${CMK_LIVESTATUS_TCP:-off}"; then printf '6557/tcp|Livestatus ueber TCP|ja\n' else printf '6557/tcp|Livestatus - aus (CMK_LIVESTATUS_TCP=off)|nein\n' fi # --- Event Console ------------------------------------------------- case "$(fw_lc "${FW_EC_SYSLOG:-off}")" in udp) printf '514/udp|Event Console: Syslog|ja\n' ;; tcp) printf '514/tcp|Event Console: Syslog ueber TCP|ja\n' ;; beide|both|on) printf '514/udp|Event Console: Syslog|ja\n' printf '514/tcp|Event Console: Syslog ueber TCP|ja\n' ;; esac if fw_on "${FW_EC_SNMPTRAP:-off}"; then printf '162/udp|Event Console: SNMP-Traps|ja\n' fi if fw_on "${FW_EC_TCP:-off}"; then printf '6558/tcp|Event Console: Status und Ereignisse ueber TCP|ja\n' fi # --- Agent auf diesem Host ---------------------------------------- if fw_on "${FW_AGENT:-off}"; then printf '6556/tcp|Checkmk-Agent dieses Hosts (Pull-Betrieb)|ja\n' fi # --- Frei eingetragene Ports --------------------------------------- # Form: 3000/tcp=Grafana,5667/tcp=NSCA local e pp d while IFS= read -r e; do e="$(printf '%s' "$e" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" [ -n "$e" ] || continue pp="${e%%=*}"; d="${e#*=}" if [ "$d" = "$e" ] || [ -z "$d" ]; then d="zusaetzlich freigegeben"; fi case "$pp" in */tcp|*/udp) : ;; *) pp="$pp/tcp" ;; esac printf '%s|%s|ja\n' "$pp" "$d" done < <(printf '%s\n' "${FW_EXTRA_PORTS:-}" | tr ',' '\n') } # --------------------------------------------------------------------- # Offene Ports der erkannten Firewall einsammeln, "nummer/proto" je Zeile. # Bereiche (6000-6010 bzw. 6000:6010) bleiben stehen und werden beim # Vergleich aufgeloest. # --------------------------------------------------------------------- fw_open_ports() { # backend case "$1" in firewalld) { local zone svc while IFS= read -r zone; do [ -n "$zone" ] || continue firewall-cmd --zone="$zone" --list-ports 2>/dev/null | tr ' ' '\n' firewall-cmd --zone="$zone" --list-services 2>/dev/null | tr ' ' '\n' \ | while IFS= read -r svc; do if [ -n "$svc" ]; then fw_service_port "$svc"; fi done firewall-cmd --zone="$zone" --list-rich-rules 2>/dev/null \ | sed -n 's/.*port port="\([0-9:-]*\)" protocol="\([a-z]*\)".*/\1\/\2/p' done < <(fw_zones) } | sed '/^$/d' | sort -u ;; ufw) local spec ports proto pp ufw status 2>/dev/null | awk 'NR>3 {print $1}' \ | while IFS= read -r spec; do case "$spec" in *[0-9]*) : ;; *) continue ;; esac proto=""; ports="$spec" case "$spec" in */tcp|*/udp) proto="${spec##*/}"; ports="${spec%/*}" ;; esac for pp in $(printf '%s' "$ports" | tr ',' ' '); do case "$pp" in *[!0-9:-]*) continue ;; esac if [ -n "$proto" ]; then printf '%s/%s\n' "$pp" "$proto" else printf '%s/tcp\n%s/udp\n' "$pp" "$pp" fi done done | sort -u ;; nftables) local proto rest pp nft list ruleset 2>/dev/null \ | grep -oE '(tcp|udp) dport[[:space:]]*(\{[^}]*\}|[0-9]+(-[0-9]+)?)' \ | sed 's/dport//; s/[{}]//g' \ | while read -r proto rest; do for pp in $(printf '%s' "$rest" | tr ',' ' '); do printf '%s/%s\n' "$pp" "$proto" done done | sort -u ;; iptables) local line proto ports pp iptables -S 2>/dev/null | grep -E ' -j (ACCEPT|accept)$' \ | while IFS= read -r line; do proto="$(printf '%s' "$line" | sed -n 's/.* -p \([a-z0-9]*\).*/\1/p')" case "$proto" in tcp|udp) : ;; *) continue ;; esac ports="$(printf '%s' "$line" \ | grep -oE '\-\-(dport|dports) [0-9,:]+' \ | sed 's/--dports\? //')" [ -n "$ports" ] || continue for pp in $(printf '%s' "$ports" | tr ',' ' '); do printf '%s/%s\n' "$pp" "$proto" done done | sort -u ;; *) : ;; esac } # Steht port/proto in der Liste der offenen? Bereiche werden aufgeloest. fw_port_open() { # port proto (Liste in _FW_OPEN, eine je Zeile) local port="$1" proto="$2" e ep epr lo hi while IFS= read -r e; do [ -n "$e" ] || continue ep="${e%/*}"; epr="${e##*/}" [ "$epr" = "$proto" ] || continue case "$ep" in *[-:]*) lo="${ep%%[-:]*}"; hi="${ep##*[-:]}" case "$lo$hi" in *[!0-9]*) continue ;; esac if [ "$port" -ge "$lo" ] && [ "$port" -le "$hi" ]; then return 0; fi ;; *) if [ "$ep" = "$port" ]; then return 0; fi ;; esac done <<<"${_FW_OPEN:-}" return 1 } # Vorschlaege zum Oeffnen fw_open_cmds() { # backend port proto [quelle] local be="$1" port="$2" proto="$3" src="${4:-}" z case "$be" in firewalld) z="$(fw_zones | head -n1)" if [ -n "$src" ]; then printf "firewall-cmd --permanent --zone=%s --add-rich-rule='rule family=ipv4 source address=%s port port=%s protocol=%s accept'\n" \ "${z:-public}" "$src" "$port" "$proto" else printf 'firewall-cmd --permanent --zone=%s --add-port=%s/%s\n' "${z:-public}" "$port" "$proto" fi ;; ufw) if [ -n "$src" ]; then printf 'ufw allow from %s to any port %s proto %s\n' "$src" "$port" "$proto" else printf 'ufw allow %s/%s\n' "$port" "$proto" fi ;; nftables) if [ -n "$src" ]; then printf 'nft add rule inet filter input ip saddr %s %s dport %s accept\n' "$src" "$proto" "$port" else printf 'nft add rule inet filter input %s dport %s accept\n' "$proto" "$port" fi ;; iptables) if [ -n "$src" ]; then printf 'iptables -I INPUT -s %s -p %s --dport %s -j ACCEPT\n' "$src" "$proto" "$port" else printf 'iptables -I INPUT -p %s --dport %s -j ACCEPT\n' "$proto" "$port" fi ;; esac } # --------------------------------------------------------------------- # Ports wieder zumachen # # "firewall open" kennt nur eine Richtung. Nimmt man einen Dienst # wieder aus der Konfiguration - Event Console abgeschaltet, ein Port # aus FW_EXTRA_PORTS gestrichen -, bleibt die Freigabe stehen. # # Zugemacht wird nur, was dieser Aufbau kennt: die Ports aus der Liste # unten und das, was "firewall open --apply" selbst geoeffnet hat. # Fremde Freigaben bleiben unangetastet - wer 3306 fuer seine Datenbank # offen hat, soll sie behalten. # # SSH wird nie geschlossen. Weder Port 22 noch der Port, auf dem sshd # tatsaechlich lauscht, noch der, ueber den diese Sitzung laeuft. # --------------------------------------------------------------------- # Alle Ports, die zu diesem Aufbau gehoeren koennten - unabhaengig davon, # ob sie gerade gebraucht werden fw_known_ports() { printf '80/tcp|HTTP\n' printf '443/tcp|HTTPS\n' printf '%s/tcp|Adminoberflaeche des Proxy Managers\n' "${NPM_ADMIN_PORT:-81}" printf '5000/tcp|Checkmk direkt\n' printf '8000/tcp|Agent-Receiver\n' printf '6557/tcp|Livestatus\n' printf '6558/tcp|Event Console ueber TCP\n' printf '6556/tcp|Checkmk-Agent dieses Hosts\n' printf '514/udp|Event Console: Syslog\n' printf '514/tcp|Event Console: Syslog ueber TCP\n' printf '162/udp|Event Console: SNMP-Traps\n' # Was wir selbst geoeffnet haben, gehoert auch dazu local e while IFS= read -r e; do [ -n "$e" ] || continue printf '%s|selbst geoeffnet\n' "$e" done < <(fw_state_list) } fw_state_file() { printf '%s/%s\n' "$PROJECT_DIR" "$FW_STATE_FILE_NAME"; } fw_state_list() { local f; f="$(fw_state_file)" [ -f "$f" ] || return 0 sed 's/#.*//; s/[[:space:]]//g' "$f" | sed '/^$/d' | sort -u } fw_state_add() { # port/proto local f; f="$(fw_state_file)" if [ ! -f "$f" ]; then cat >"$f" <<'EOF' # Von "cmk-manage.sh firewall open --apply" geoeffnete Ports. # Dient "firewall close" als Gedaechtnis - hier stehen nur Eintraege, # die dieses Werkzeug selbst gesetzt hat. Von Hand geaendert werden # muss hier nichts. EOF fi ( set +o pipefail; grep -qxF "$1" "$f" ) || printf '%s\n' "$1" >>"$f" } fw_state_del() { # port/proto local f tmp; f="$(fw_state_file)" [ -f "$f" ] || return 0 tmp="$f.tmp" # Bleibt nichts uebrig, liefert grep 1 - das darf weder die Datei # stehen lassen noch unter "set -e" den Ablauf beenden. grep -vxF "$1" "$f" > "$tmp" 2>/dev/null || true mv "$tmp" "$f" 2>/dev/null || rm -f "$tmp" return 0 } # Ports, die unter keinen Umstaenden geschlossen werden fw_protected_ports() { printf '22/tcp\n' # Wo lauscht sshd wirklich? if command -v sshd >/dev/null 2>&1; then sshd -T 2>/dev/null | sed -n 's/^port[[:space:]]\+\([0-9]\+\).*/\1\/tcp/p' fi sed -n 's/^[[:space:]]*[Pp]ort[[:space:]]\+\([0-9]\+\).*/\1\/tcp/p' \ /etc/ssh/sshd_config 2>/dev/null # Und der Port, ueber den diese Sitzung gerade laeuft if [ -n "${SSH_CONNECTION:-}" ]; then printf '%s/tcp\n' "$(printf '%s' "$SSH_CONNECTION" | awk '{print $4}')" fi } # Kommandos zum Schliessen. Anders als beim Oeffnen reicht eine Vorlage # nicht: die Regel muss so getroffen werden, wie sie dasteht. fw_close_cmds() { # backend port proto local be="$1" port="$2" proto="$3" case "$be" in firewalld) local zone svc rule while IFS= read -r zone; do [ -n "$zone" ] || continue local plist plist="$(firewall-cmd --zone="$zone" --list-ports 2>/dev/null | tr ' ' '\n')" if ( set +o pipefail; printf '%s\n' "$plist" | grep -qxF "$port/$proto" ); then printf 'firewall-cmd --permanent --zone=%s --remove-port=%s/%s\n' "$zone" "$port" "$proto" fi while IFS= read -r svc; do [ -n "$svc" ] || continue if ( set +o pipefail; fw_service_port "$svc" | grep -qxF "$port/$proto" ); then printf 'firewall-cmd --permanent --zone=%s --remove-service=%s\n' "$zone" "$svc" fi done < <(firewall-cmd --zone="$zone" --list-services 2>/dev/null | tr ' ' '\n') while IFS= read -r rule; do [ -n "$rule" ] || continue case "$rule" in *"port=\"$port\""*"protocol=\"$proto\""*) printf "firewall-cmd --permanent --zone=%s --remove-rich-rule='%s'\n" "$zone" "$rule" ;; esac done < <(firewall-cmd --zone="$zone" --list-rich-rules 2>/dev/null) done < <(fw_zones) ;; ufw) # NICHT nach Nummern loeschen: die verschieben sich beim # ersten Loeschen, und der naechste Aufruf traefe die falsche # Regel. Ueber die Regelangabe ist es von der Reihenfolge # unabhaengig. Deckt eine Regel mehrere Ports ab, bleibt sie # stehen - sonst faellt mehr weg als gewollt. ufw status numbered 2>/dev/null \ | awk -v p="$port" -v pr="$proto" ' /^\[/ { line = $0 sub(/^\[[ \t]*[0-9]+\][ \t]*/, "", line) if (line ~ /\(v6\)/) next # ufw loescht v4 und v6 zusammen n = split(line, f, /[ \t]+/) spec = f[1]; act = ""; from = "" for (i = 2; i <= n; i++) { if (f[i] == "ALLOW" || f[i] == "DENY" || f[i] == "LIMIT" || f[i] == "REJECT") { act = f[i]; j = i + 1 if (f[j] == "IN" || f[j] == "OUT" || f[j] == "FWD") j++ for (; j <= n; j++) from = from (from == "" ? "" : " ") f[j] break } } if (act != "ALLOW") next split(spec, a, "/") plist = a[1]; sp = (length(a) > 1 ? a[2] : "") if (sp != "" && sp != pr) next m = split(plist, b, ",") for (i = 1; i <= m; i++) if (b[i] == p) { if (m > 1) { printf "# Regel \"%s\" deckt mehrere Ports ab - bitte von Hand: ufw status numbered\n", spec } else if (from == "Anywhere" || from == "") { printf "ufw delete allow %s\n", spec } else { printf "ufw delete allow from %s to any port %s", from, p if (sp != "") printf " proto %s", sp printf "\n" } next } }' ;; iptables) # Die Regel so loeschen, wie sie eingetragen ist. Sammelregeln # (--dports 80,443) bleiben stehen, sonst faellt mehr weg als # gewollt. iptables -S 2>/dev/null | grep -E ' -j (ACCEPT|accept)$' \ | while IFS= read -r line; do case "$line" in "-A "*) : ;; *) continue ;; esac case "$line" in *" -p $proto "*) : ;; *) continue ;; esac local plist="" plist="$(printf '%s' "$line" | sed -n 's/.*--dports\{0,1\} \([0-9,:]*\).*/\1/p')" [ -n "$plist" ] || continue case ",$plist," in *",$port,"*) : ;; *) continue ;; esac case "$plist" in *,*) printf '# Regel deckt mehrere Ports ab (%s) - bitte von Hand: iptables -S\n' "$plist" ;; *) printf 'iptables -D %s\n' "${line#-A }" ;; esac done ;; nftables) # Geloescht wird ueber den Handle. Steht der Port in einer # Menge mit anderen, bleibt die Regel stehen. nft -a list ruleset 2>/dev/null \ | awk -v p="$port" -v pr="$proto" ' /^table / { fam = $2; tbl = $3 } /^[[:space:]]*chain / { ch = $2 } $0 ~ (pr " dport") && $0 ~ /handle [0-9]+$/ { line = $0 h = line; sub(/.*handle /, "", h) if (match(line, /dport[ \t]*\{[^}]*\}/)) { set = substr(line, RSTART, RLENGTH) sub(/dport[ \t]*\{/, "", set); sub(/\}/, "", set) gsub(/[ \t]/, "", set) n = split(set, a, ",") for (i = 1; i <= n; i++) if (a[i] == p) { if (n > 1) printf "# Regel %s deckt mehrere Ports ab (%s) - bitte von Hand: nft -a list ruleset\n", h, set else printf "nft delete rule %s %s %s handle %s\n", fam, tbl, ch, h } } else if (match(line, ("dport[ \t]+" p "([^0-9]|$)"))) { printf "nft delete rule %s %s %s handle %s\n", fam, tbl, ch, h } }' ;; esac } # Welche Ports sind offen, gehoeren zu diesem Aufbau, werden aber nicht # mehr gebraucht? fw_close_report() { # backend apply local be="$1" apply="${2:-}" local spec need port proto desc local -a todo=() # Was gebraucht wird, darf nicht weg local noetig="" while IFS='|' read -r spec desc need; do [ -n "$spec" ] || continue case "$need" in ja|admin) noetig="$noetig$spec"$'\n' ;; esac done < <(fw_required_ports) local geschuetzt; geschuetzt="$(fw_protected_ports | sed '/^$/d' | sort -u)" printf '\n%sNicht mehr benoetigt%s\n' "$C_BLD" "$C_RST" printf ' %-10s %-44s %s\n' "PORT" "GEHOERT ZU" "ZUSTAND" local seen="" while IFS='|' read -r spec desc; do [ -n "$spec" ] || continue case "$seen" in *"|$spec|"*) continue ;; esac seen="$seen|$spec|" port="${spec%/*}"; proto="${spec##*/}" fw_port_open "$port" "$proto" || continue if ( set +o pipefail; printf '%s' "$noetig" | grep -qxF "$spec" ); then continue; fi if ( set +o pipefail; printf '%s' "$geschuetzt" | grep -qxF "$spec" ); then printf ' %-10s %-44s %b\n' "$spec" "$desc" "${C_GRN}bleibt offen (SSH)${C_RST}" continue fi todo+=("$spec") printf ' %-10s %-44s %b\n' "$spec" "$desc" "${C_YEL}kann zu${C_RST}" done < <(fw_known_ports) if [ "${#todo[@]}" -eq 0 ]; then echo ok "Es ist nichts offen, was nicht gebraucht wird." printf ' Fremde Freigaben - etwa fuer andere Dienste auf diesem Rechner - werden dabei nicht angeruehrt.\n\n' return 0 fi printf '\n%sZum Schliessen%s\n' "$C_BLD" "$C_RST" local t c n=0 for t in "${todo[@]}"; do while IFS= read -r c; do [ -n "$c" ] || continue printf ' %s\n' "$c"; n=$((n+1)) done < <(fw_close_cmds "$be" "${t%/*}" "${t##*/}") done if [ "$n" -eq 0 ]; then echo warn "Die Regeln liessen sich nicht zuordnen - bitte von Hand pruefen." echo; return 0 fi case "$be" in firewalld) printf ' firewall-cmd --reload\n' ;; iptables) printf ' # dauerhaft machen: iptables-save bzw. iptables-persistent\n' ;; nftables) printf ' # dauerhaft machen: /etc/nftables.conf nachziehen\n' ;; esac if [ -z "$apply" ]; then printf '\n Ausfuehren mit: %s firewall close --apply\n\n' "$SELF" return 0 fi echo if [ "$(id -u)" -ne 0 ]; then die "Dafuer werden root-Rechte benoetigt - bitte mit sudo erneut aufrufen." fi confirm "Diese Ports jetzt schliessen?" || die "Abgebrochen." for t in "${todo[@]}"; do local fehler="" while IFS= read -r c; do [ -n "$c" ] || continue case "$c" in \#*) warn "${c#\# }"; fehler=1; continue ;; esac log "$c" eval "$c" || { warn "Fehlgeschlagen: $c"; fehler=1; } done < <(fw_close_cmds "$be" "${t%/*}" "${t##*/}") if [ -z "$fehler" ]; then fw_state_del "$t"; fi done if [ "$be" = firewalld ]; then firewall-cmd --reload >/dev/null 2>&1 && ok "firewalld neu geladen." \ || warn "Neuladen fehlgeschlagen." fi ok "Ports geschlossen." echo return 0 } cmd_firewall() { local sub="${1:-check}"; [ $# -gt 0 ] && shift || true case "$sub" in check|open|close) : ;; *) die "Unbekannt: firewall $sub (check|open|close)" ;; esac local apply="" src="" while [ $# -gt 0 ]; do case "$1" in --apply) apply=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; --source) src="$2"; shift 2 ;; *) die "Unbekannte Option fuer 'firewall': $1" ;; esac done local be role; be="$(fw_detect)" # Zonen einmal ermitteln - sonst fragt jede Regelzeile erneut nach if [ "$be" = firewalld ]; then _FW_ZONES=""; _FW_ZONES="$(fw_zones)"; fi role="$(fw_lc "${FW_ROLE:-single}")" case "$role" in central|remote) : ;; *) role=single ;; esac printf '\n%sFirewall%s\n' "$C_BLD" "$C_RST" case "$be" in keine) test_result warn "Keine aktive Firewall erkannt" printf ' Entweder ist keine eingerichtet, oder die Pruefung braucht root-Rechte. Mit sudo erneut aufrufen.\n' ;; *) test_result ok "Erkannt: $be" if [ "$be" = firewalld ]; then printf ' Aktive Zonen: %s\n' "$(fw_zones | tr '\n' ' ' | sed 's/ $//')" fi ;; esac case "$role" in central) printf ' Rolle: Zentrale einer verteilten Ueberwachung\n' ;; remote) printf ' Rolle: Remote-Site einer verteilten Ueberwachung\n' ;; esac _FW_OPEN="" if [ "$be" != keine ]; then _FW_OPEN="$(fw_open_ports "$be")" printf ' Offene Ports: %s\n' "$(printf '%s' "$_FW_OPEN" | tr '\n' ' ' | sed 's/ $//')" fi if [ "$sub" = close ]; then if [ "$be" = keine ]; then echo; warn "Ohne erkannte Firewall gibt es nichts zu schliessen."; echo return 0 fi fw_close_report "$be" "$apply" return $? fi printf '\n%sWas dieser Stack braucht%s\n' "$C_BLD" "$C_RST" printf ' %-10s %-48s %-7s %s\n' "PORT" "ZWECK" "NOETIG" "ZUSTAND" local spec zweck need zustand port proto ist fehlend=0 local -a todo=() while IFS='|' read -r spec zweck need; do [ -n "$spec" ] || continue port="${spec%/*}"; proto="${spec##*/}" ist=nein if [ "$be" != keine ] && fw_port_open "$port" "$proto"; then ist=ja; fi case "$need:$ist" in ja:ja) zustand="${C_GRN}offen${C_RST}" ;; ja:nein) zustand="${C_RED}GESCHLOSSEN${C_RST}"; fehlend=$((fehlend+1)); todo+=("$spec") ;; admin:ja) zustand="offen - besser auf das Adminnetz begrenzen" ;; admin:nein) zustand="geschlossen" ;; nein:ja) zustand="${C_YEL}offen - sollte zu sein${C_RST}" ;; nein:nein) zustand="zu (richtig)" ;; esac if [ "$be" = keine ]; then zustand="nicht pruefbar"; fi printf ' %-10s %-48s %-7s %b\n' "$spec" "$zweck" "$need" "$zustand" done < <(fw_required_ports) # Hinweise, die sich aus der Konfiguration ergeben local -a hinweise=() if [ "$role" = remote ] && ! fw_on "${CMK_LIVESTATUS_TCP:-off}"; then hinweise+=("Rolle 'remote', aber CMK_LIVESTATUS_TCP=off - dann lauscht auf 6557 nichts. Einschalten mit: $SELF config set CMK_LIVESTATUS_TCP on (danach 'up')") fi if [ "$role" = central ]; then hinweise+=("Als Zentrale baut diese Site die Verbindungen selbst auf: nach aussen muessen 6557/tcp und 443/tcp zu den Remote-Sites erlaubt sein. Eingehend braucht die Zentrale dafuer nichts - ausser dem, was oben steht.") fi if [ "${#hinweise[@]}" -gt 0 ]; then printf '\n%sHinweise%s\n' "$C_BLD" "$C_RST" local h; for h in "${hinweise[@]}"; do printf ' %s\n' "$h"; done fi if [ "$be" = keine ]; then echo; return 0; fi echo if [ "$fehlend" -eq 0 ]; then ok "Alle benoetigten Ports sind offen." echo; return 0 fi err "$fehlend benoetigte(r) Port(s) sind geschlossen." printf '\n%sZum Oeffnen%s\n' "$C_BLD" "$C_RST" local t for t in "${todo[@]}"; do printf ' %s\n' "$(fw_open_cmds "$be" "${t%/*}" "${t##*/}" "$src")" done case "$be" in firewalld) printf ' firewall-cmd --reload\n' ;; iptables) printf ' # dauerhaft machen: iptables-save bzw. das Paket iptables-persistent\n' ;; nftables) printf ' # dauerhaft machen: in /etc/nftables.conf uebernehmen\n' ;; esac if [ -n "$src" ]; then case " ${todo[*]} " in *" 80/tcp "*|*" 443/tcp "*) printf '\n Achtung: Lets Encrypt prueft die Domain von aussen ueber Port 80. Wird 80 auf ein Quellnetz begrenzt, schlaegt die Ausstellung fehl.\n' ;; esac fi if [ -n "$apply" ]; then echo if [ "$(id -u)" -ne 0 ]; then die "Dafuer werden root-Rechte benoetigt - bitte mit sudo erneut aufrufen." fi confirm "Diese Regeln jetzt setzen?" || die "Abgebrochen." local c for t in "${todo[@]}"; do c="$(fw_open_cmds "$be" "${t%/*}" "${t##*/}" "$src")" log "$c" if eval "$c"; then # Merken, damit "firewall close" spaeter weiss, was von uns war fw_state_add "$t" else warn "Fehlgeschlagen: $c" fi done if [ "$be" = firewalld ]; then firewall-cmd --reload >/dev/null 2>&1 && ok "firewalld neu geladen." \ || warn "Neuladen fehlgeschlagen." fi ok "Regeln gesetzt." echo # Gesetzt ist gesetzt - der Rueckgabewert darf jetzt nicht mehr # den urspruenglichen Befund melden, sonst liest die Oberflaeche # das als fehlgeschlagen. return 0 else printf '\n Automatisch setzen: %s firewall open --apply\n' "$SELF" printf ' Nur fuer ein Netz : %s firewall open --apply --source 10.0.0.0/24\n' "$SELF" fi echo return 1 } # ===================================================================== # Selbstpruefung # # Faengt die Fehler ab, die hier wiederholt aufgetreten sind: # 1. Dialoge, die an den Engstellen vorbei direkt "$UI_BIN" aufrufen - # die haetten dann wieder feste Fenstergroessen. # 2. Feste Zahlen als Fenstergroesse. # 3. "[ bedingung ] && VAR=wert": liefert 1, wenn die Bedingung nicht # zutrifft, und beendet unter "set -e" Funktion oder Skript. # Aufruf: cmk-manage.sh selftest # ===================================================================== cmd_selftest() { local rc=0 n local -a files=("$SCRIPT_DIR/cmk-manage.sh" "$SCRIPT_DIR/lib/common.sh" \ "$SCRIPT_DIR/migrate_creator.sh" "$PROJECT_DIR/install.sh") printf '\n%s1. Syntax%s\n' "$C_BLD" "$C_RST" local f for f in "${files[@]}"; do [ -f "$f" ] || continue if bash -n "$f" 2>/dev/null; then printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$(basename "$f")" else printf ' %s[FEHLER]%s %s\n' "$C_RED" "$C_RST" "$(basename "$f")"; rc=1 fi done printf '\n%s2. Dialoge laufen ueber die Engstellen%s\n' "$C_BLD" "$C_RST" n=0 for f in "$SCRIPT_DIR/cmk-manage.sh" "$SCRIPT_DIR/lib/common.sh" "$SCRIPT_DIR/migrate_creator.sh"; do [ -f "$f" ] || continue # Aufrufe von "$UI_BIN" sind nur in _ui_list/_ui_box/_ui_file erlaubt while IFS= read -r line; do [ -n "$line" ] || continue printf ' %s[FEHLER]%s %s:%s\n' "$C_RED" "$C_RST" "$(basename "$f")" "$line" n=$((n+1)) done < <(awk ' /^[_a-zA-Z0-9]+\(\) \{/ { fn = $1; sub(/\(\).*/, "", fn) } /"\$UI_BIN"[[:space:]]+--/ { if ($0 ~ /^[[:space:]]*#/) next if (fn ~ /^_ui_/ || fn == "ui_available") next printf "%d: %s\n", NR, substr($0, 1, 60) }' "$f") done if [ "$n" -eq 0 ]; then printf ' %s[ OK ]%s kein Dialog ruft dialog/whiptail direkt auf\n' "$C_GRN" "$C_RST" else printf ' Neue Fenster bitte ueber ui_menu/ui_msg/... bauen.\n'; rc=1 fi printf '\n%s3. Keine festen Fenstergroessen%s\n' "$C_BLD" "$C_RST" n=0 for f in "$SCRIPT_DIR/lib/common.sh" "$SCRIPT_DIR/migrate_creator.sh"; do [ -f "$f" ] || continue while IFS= read -r line; do [ -n "$line" ] || continue printf ' %s[FEHLER]%s %s:%s\n' "$C_RED" "$C_RST" "$(basename "$f")" "$line" n=$((n+1)) done < <(grep -nE '"\$UI_BIN".*(--menu|--checklist|--radiolist|--[a-z]+box).*[[:space:]][0-9]{1,3}[[:space:]]' "$f" \ | cut -c1-70 || true) done [ "$n" -eq 0 ] && printf ' %s[ OK ]%s alle Groessen werden berechnet\n' "$C_GRN" "$C_RST" || rc=1 printf '\n%s4. Keine "[ ... ] && VAR=" unter set -e%s\n' "$C_BLD" "$C_RST" n=0 for f in "${files[@]}"; do [ -f "$f" ] || continue while IFS= read -r line; do [ -n "$line" ] || continue printf ' %s[ WARN ]%s %s:%s\n' "$C_YEL" "$C_RST" "$(basename "$f")" "$line" n=$((n+1)) done < <(grep -nE '^[[:space:]]*\[[^]]+\][[:space:]]*&&[[:space:]]*[A-Za-z_][A-Za-z_0-9]*=' "$f" \ | cut -c1-70 || true) done if [ "$n" -eq 0 ]; then printf ' %s[ OK ]%s keine Fundstellen\n' "$C_GRN" "$C_RST" else printf ' Als letzte Anweisung einer Funktion beendet das unter\n' printf ' "set -e" das Skript. Besser als if-Block schreiben.\n' fi printf '\n%s5. Keine "| grep -q" ohne Schutz vor SIGPIPE%s\n' "$C_BLD" "$C_RST" n=0 for f in "${files[@]}"; do [ -f "$f" ] || continue while IFS= read -r line; do [ -n "$line" ] || continue printf ' %s[FEHLER]%s %s:%s\n' "$C_RED" "$C_RST" "$(basename "$f")" "$line" n=$((n+1)) done < <(grep -nE '\|[[:space:]]*grep -[a-zA-Z]*q' "$f" \ | grep -v 'set +o pipefail' \ | grep -vE '^[0-9]+:[[:space:]]*#' \ | grep -v 'printf' | cut -c1-72 || true) done if [ "$n" -eq 0 ]; then printf ' %s[ OK ]%s alle solchen Pipes laufen ohne pipefail\n' "$C_GRN" "$C_RST" else printf ' "grep -q" endet beim ersten Treffer; der Erzeuger bekommt\n' printf ' SIGPIPE und unter "set -o pipefail" gilt die Pipe als\n' printf ' fehlgeschlagen - abhaengig von der Datenmenge. Schreibweise:\n' printf ' if ( set +o pipefail; erzeuger | grep -q muster ); then\n' rc=1 fi printf '\n%s6. Zeichensatz fuer die Oberflaeche%s\n' "$C_BLD" "$C_RST" local before="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" ui_fix_locale || true printf ' Locale vorher : %s\n' "$before" printf ' Locale jetzt : %s\n' "${LC_CTYPE:-${LANG:-}}" if [ -n "${UI_ASCII:-}" ]; then printf ' %s[ WARN ]%s Keine UTF-8-Locale vorhanden - die Rahmen werden einfach gezeichnet.\n' "$C_YEL" "$C_RST" printf ' Abhilfe: dnf install -y glibc-langpack-en bzw. locale-gen\n' else printf ' %s[ OK ]%s UTF-8 - die Rahmen werden sauber gezeichnet%s\n' "$C_GRN" "$C_RST" \ "$([ -n "${UI_LOCALE_NOTE:-}" ] && echo " (${UI_LOCALE_NOTE})")" fi printf '\n%s7. Einstellungstabelle%s\n' "$C_BLD" "$C_RST" n="$(settings_defs | awk -F'|' 'NF!=6' | wc -l)" if [ "$n" -eq 0 ]; then printf ' %s[ OK ]%s alle Zeilen haben sechs Felder\n' "$C_GRN" "$C_RST" else printf ' %s[FEHLER]%s %s Zeile(n) mit falscher Feldzahl\n' "$C_RED" "$C_RST" "$n"; rc=1 fi local missing=0 k for k in $(settings_defs | cut -d'|' -f2); do grep -qE "^${k}=" "$PROJECT_DIR/.env.example" || { printf ' %s[FEHLER]%s %s fehlt in .env.example\n' "$C_RED" "$C_RST" "$k"; missing=1; } done [ "$missing" -eq 0 ] && printf ' %s[ OK ]%s alle Schluessel stehen in .env.example\n' "$C_GRN" "$C_RST" || rc=1 echo if [ "$rc" -eq 0 ]; then ok "Selbstpruefung bestanden."; else err "Selbstpruefung hat Probleme gefunden."; fi echo return "$rc" } # ===================================================================== # Einstiegspunkt # ===================================================================== main() { while [ $# -gt 0 ]; do case "$1" in --env-file) ENVFILE_OPT="$2"; shift 2 ;; -y|--yes) ASSUME_YES=1; shift ;; -v|--verbose) VERBOSE=1; shift ;; -h|--help) usage; exit 0 ;; *) break ;; esac done load_env "${ENVFILE_OPT:-$PROJECT_DIR/.env}" detect_runtime UI_BACKTITLE="Checkmk Stack Manager - Runtime: $RUNTIME" local cmd="${1:-gui}"; [ $# -gt 0 ] && shift || true case "$cmd" in gui|menu) gui_main ;; up|start) if [ "${1:-}" = "--no-fetch" ]; then UP_NO_FETCH=1; fi cmd_up ;; down|stop) cmd_down ;; restart) cmd_restart || true ;; pull) cmd_pull || true ;; status) cmd_status || true ;; logs) cmd_logs "$@" || true ;; shell) cmd_shell || true ;; omd) cmd_omd "$@" || true ;; sites) cmd_sites || true ;; site-start) cmd_site_start "$@" ;; site-stop) cmd_site_stop "$@" ;; start-all-sites) cmd_start_all_sites ;; backup) cmd_backup "$@" || true ;; list) cmd_list || true ;; prune) cmd_prune "$@" || true ;; job|jobs) cmd_job "$@" ;; verify) local vf=""; [ "${1:-}" = "--file" ] && vf="$2"; [ -n "$vf" ] || vf="${1:-}" [ -n "$vf" ] || die "--file fehlt."; bundle_verify "$vf" ;; restore) cmd_restore "$@" || true ;; import) cmd_import "$@" || true ;; cert) cmd_cert "$@" || true ;; image) cmd_image "$@" || true ;; browser) cmd_browser "$@" || true ;; npm-port) cmd_npm_port "$@" ;; npm-enable) cmd_npm_enable "$@" ;; npm-reset) cmd_npm_reset ;; proxy) cmd_proxy "$@" ;; config) cmd_config "$@" || true ;; selftest) cmd_selftest ;; tui-check) cmd_tui_check ;; firewall) cmd_firewall "$@" ;; help|-h|--help) usage ;; *) err "Unbekanntes Kommando: $cmd"; echo; usage; exit 1 ;; esac } main "$@"