#!/usr/bin/env bash # # Laufzettel in einen frischen LXC auf Proxmox VE ausrollen. # # Aufruf auf der Proxmox-Shell als root: # # bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)" # # Dieses Skript legt den Container an, startet ihn und ruft darin deploy/install.sh # auf. Es installiert selbst nichts: die gesamte Installationslogik steht in # install.sh, damit der native Weg (Debian ohne Proxmox) und der LXC-Weg nicht # auseinanderlaufen (#019). # # Trockenlauf ohne Proxmox-Host: bash deploy/proxmox/laufzettel.sh --dry-run # set -Eeuo pipefail # --------------------------------------------------------------------------- # Feste Werte # --------------------------------------------------------------------------- readonly SKRIPTNAME="laufzettel.sh" readonly REPO_ROH="https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch" readonly REPO_GIT="https://git.wilde-server.de/florian.wilde/Laufzettel.git" readonly ENV_DATEI="/etc/laufzettel/laufzettel.env" # Leihgabe von install.sh, kein Aufbewahrungsort: dieses Skript liest das # Erstpasswort daraus und löscht die Datei sofort wieder. Siehe abschluss_zeigen(). readonly PASSWORT_DATEI="/root/laufzettel-erstpasswort" # Deutsche Anführungszeichen als Variablen: in doppelten Anführungszeichen # hält shellcheck sie für einen Tippfehler (SC1111), als Literal in einfachen # ist es eindeutig. Lesbar bleibt der Text trotzdem richtig. readonly ANF_AUF='„' readonly ANF_ZU='“' # Vorgaben des Standardwegs. Der Erweitert-Dialog nimmt sie als Vorbelegung. readonly STD_HOSTNAME="laufzettel" readonly STD_DEBIAN="13" readonly STD_KERNE="2" readonly STD_RAM="2048" readonly STD_PLATTE="8" readonly STD_SWAP="512" readonly STD_BRUECKE="vmbr0" readonly STD_IP="dhcp" readonly STD_UNPRIV="1" # Laufzeitwerte, aus dem Dialog oder aus den Vorgaben. CTID="" HOSTNAME_CT="$STD_HOSTNAME" DEBIAN="$STD_DEBIAN" KERNE="$STD_KERNE" RAM="$STD_RAM" PLATTE="$STD_PLATTE" SWAP="$STD_SWAP" BRUECKE="$STD_BRUECKE" IP_WUNSCH="$STD_IP" GATEWAY="" UNPRIV="$STD_UNPRIV" SPEICHER_ROOT="" SPEICHER_VORLAGE="" VORLAGE="" CT_IP="" TROCKEN="nein" MODUS="frage" # frage | standard | erweitert NUR_AKTUALISIEREN="nein" ZWEIG="main" # Steht auf ja, sobald "pct create" gelaufen ist, und wieder auf nein, sobald # der Container fertig eingerichtet ist. Nur dazwischen darf das Aufräumen ihn # wieder entfernen. HALB_ANGELEGT="nein" # Ergebnis der letzten Leseoperation, siehe pve_lesen(). ANTWORT="" # Nummer des laufenden Abschnitts. Kommt aus der Position, wird nie gespeichert. SCHRITT=0 # --------------------------------------------------------------------------- # Ausgabe # --------------------------------------------------------------------------- # Farbe nur, wenn die Ausgabe wirklich an ein Terminal geht — sonst landen die # Steuerzeichen in Protokollen und Pipes. if [ -t 1 ]; then F_AUS=$'\033[0m' F_FETT=$'\033[1m' F_GRAU=$'\033[90m' F_LIME=$'\033[38;5;190m' F_ROT=$'\033[31m' F_GELB=$'\033[33m' else F_AUS="" F_FETT="" F_GRAU="" F_LIME="" F_ROT="" F_GELB="" fi kopf() { SCHRITT=$((SCHRITT + 1)) printf '\n%s%02d · %s%s\n' "$F_FETT" "$SCHRITT" "$1" "$F_AUS" printf '%s%s%s\n' "$F_GRAU" "------------------------------------------------------------" "$F_AUS" } info() { printf ' %s\n' "$1"; } notiz() { printf ' %s%s%s\n' "$F_GRAU" "$1" "$F_AUS"; } erfolg() { printf ' %sok%s %s\n' "$F_LIME" "$F_AUS" "$1"; } warnung() { printf ' %sAchtung%s %s\n' "$F_GELB" "$F_AUS" "$1" >&2; } fehlertext() { printf ' %sFehler%s %s\n' "$F_ROT" "$F_AUS" "$1" >&2; } # Bricht mit einem Satz ab, der sagt, was fehlt. Der ERR-Trap wird vorher # abgeräumt, damit ein absichtlicher Abbruch nicht als Programmfehler mit # Zeilennummer hinterhergemeldet wird. abbrechen() { trap - ERR fehlertext "$1" exit 1 } # Wie abbrechen(), lässt den Container aber ausdrücklich stehen. Für alles, was # nach dem Start schiefgeht: dort steckt meist ein Netzfehler, den man sich # ansehen will, und die Meldung verspricht, dass der Container noch da ist. abbrechen_und_behalten() { HALB_ANGELEGT="nein" abbrechen "$1" } # --------------------------------------------------------------------------- # Spinner # --------------------------------------------------------------------------- SPINNER_PID="" # Zeigt einen Spinner, solange ein langer Schritt läuft. Ohne Terminal (Pipe, # Protokoll, Trockenlauf) bleibt es bei einer Zeile Text, damit die Ausgabe # lesbar bleibt. spinner_an() { if [ ! -t 1 ] || [ "$TROCKEN" = "ja" ]; then info "$1 …" return 0 fi local text="$1" ( # Der Unterprozess setzt eigene Traps, damit ein Abbruch im Hauptprozess # nicht erst durch die Fehlerbehandlung des Spinners läuft. trap 'exit 0' TERM INT # Der Backslash steht als $'\\', damit er nicht wie ein missglückter # Maskierungsversuch aussieht (shellcheck SC1003). local zeichen=('|' '/' '-' $'\\') local i=0 while :; do i=$(((i + 1) % ${#zeichen[@]})) printf '\r %s%s%s %s' "$F_LIME" "${zeichen[i]}" "$F_AUS" "$text" sleep 0.2 done ) & SPINNER_PID=$! } spinner_aus() { if [ -n "$SPINNER_PID" ]; then kill "$SPINNER_PID" 2>/dev/null || true wait "$SPINNER_PID" 2>/dev/null || true SPINNER_PID="" printf '\r\033[2K' fi } # --------------------------------------------------------------------------- # Abbruch und Aufräumen # --------------------------------------------------------------------------- # ERR-Trap mit Zeilennummer: bei einem Fehler soll niemand raten müssen, # welcher der vielen pct-Aufrufe es war. bei_fehler() { local status="$1" zeile="$2" befehl="$3" # Nur die erste Meldung zählt: set -e lässt den Fehler aus der Funktion # heraus noch einmal aufschlagen, und der zweite Aufschlag kennt die # Zeilennummer nicht mehr. trap - ERR spinner_aus fehlertext "Zeile $zeile, Status $status: $befehl" } # Läuft in jedem Fall. Räumt nur weg, was dieser Lauf selbst angelegt hat, und # nur solange im Container noch nichts steckt, was jemand ansehen wollen könnte: # sobald install.sh angelaufen ist, bleibt der Container stehen und wird # ausdrücklich benannt, statt still verschwunden zu sein. aufraeumen() { local status=$? spinner_aus if [ "$status" -ne 0 ] && [ "$HALB_ANGELEGT" = "ja" ] && [ -n "$CTID" ]; then warnung "Container $CTID ist nur halb angelegt und wird wieder entfernt." pve_ausfuehren pct stop "$CTID" --skiplock || true pve_ausfuehren pct destroy "$CTID" --force --purge || true fi return "$status" } trap 'bei_fehler "$?" "$LINENO" "$BASH_COMMAND"' ERR trap aufraeumen EXIT trap 'fehlertext "Durch Signal abgebrochen."; exit 130' INT TERM # --------------------------------------------------------------------------- # Proxmox-Aufrufe # --------------------------------------------------------------------------- # Setzt ein Argument so, dass man es von Hand nachtippen kann. Harmlose Zeichen # bleiben nackt — "printf %q" würde jedes Komma in --net0 mit einem Backslash # versehen, und der Aufruf wäre zwar richtig, aber nicht mehr zu lesen. argument_zeigen() { case "$1" in "" | *[!A-Za-z0-9_@%+=:,./-]*) printf "'%s'" "${1//\'/\'\\\'\'}" ;; *) printf '%s' "$1" ;; esac } # Zeigt einen Befehl so, dass man ihn von Hand nachtippen kann. trocken_zeigen() { local teil ausgabe="" for teil in "$@"; do ausgabe="$ausgabe $(argument_zeigen "$teil")" done printf ' %s[trocken]%s%s\n' "$F_GELB" "$F_AUS" "$ausgabe" } # Zeigt einen Befehl, der als Text vorliegt, weil er in einer Pipeline steckt. trocken_text() { printf ' %s[trocken]%s %s\n' "$F_GELB" "$F_AUS" "$1" } # Jeder ändernde Aufruf (pct, pveam, pvesh) läuft hierüber. Im Trockenlauf wird # er nur ausgegeben. pve_ausfuehren() { if [ "$TROCKEN" = "ja" ]; then trocken_zeigen "$@" return 0 fi "$@" } # Lesende Aufrufe. Das Ergebnis steht danach in $ANTWORT und wird nicht per # Kommandosubstitution zurückgegeben: im Trockenlauf sollen auch die # Lesebefehle auf der Standardausgabe erscheinen und dürfen nicht im # eingefangenen Ergebnis landen. Das erste Argument ist der Platzhalter, mit # dem der Trockenlauf weiterrechnet — nur so läuft er ohne PVE-Host bis zum # Abschlussbildschirm durch. Das zweite Argument ist der Befehl als Text: die # Leser stecken in Pipelines und werden darum von kleinen Funktionen gekapselt, # und deren Namen wollte im Trockenlauf niemand sehen. pve_lesen() { local platzhalter="$1" anzeige="$2" shift 2 if [ "$TROCKEN" = "ja" ]; then trocken_text "$anzeige" ANTWORT="$platzhalter" if [ -n "$platzhalter" ]; then notiz "Im Trockenlauf angenommen: $platzhalter" else notiz "Im Trockenlauf angenommen: leer, also kein Treffer" fi return 0 fi ANTWORT="$("$@")" } # --------------------------------------------------------------------------- # Hilfe # --------------------------------------------------------------------------- hilfe_zeigen() { cat <<'ENDE' laufzettel.sh — rollt Laufzettel in einen frischen LXC auf Proxmox VE aus. Aufruf auf der Proxmox-Shell als root: bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)" Was es tut: 1. Prüft root und Proxmox VE 8 oder 9. 2. Fragt per whiptail Standard gegen Erweitert. Standard nimmt die nächste freie CTID, Hostname laufzettel, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf vmbr0. Ohne Terminal oder ohne whiptail gelten die Standardwerte. 3. Holt die Debian-Vorlage per pveam, falls sie noch fehlt. 4. Legt den Container an (pct create), startet ihn, wartet auf das Netz. 5. Führt deploy/install.sh im Container aus: PostgreSQL, Node, Build, Migration, Seed, systemd-Dienst. 6. Zeigt Adresse, Admin-Kennung und das erzeugte Passwort. Ein zweiter Aufruf erkennt einen vorhandenen Container am Hostnamen und bietet an, ihn zu aktualisieren, statt einen zweiten anzulegen. Optionen: --dry-run Gibt alle pct-, pveam- und pvesh-Aufrufe nur aus, legt nichts an und braucht keinen Proxmox-Host. Auch die Prüfungen auf root und PVE werden übersprungen. --standard Ohne Dialog, alle Standardwerte nehmen. --erweitert Direkt in den Erweitert-Dialog. --aktualisieren Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es keinen Container mit dem Hostnamen laufzettel gibt. --zweig Git-Zweig, aus dem install.sh geholt wird (Vorgabe: main). --hilfe, --help Diese Hilfe. Alles Weitere steht in docs/betrieb/. ENDE } # --------------------------------------------------------------------------- # Argumente # --------------------------------------------------------------------------- argumente_lesen() { while [ $# -gt 0 ]; do case "$1" in --dry-run | --trockenlauf) TROCKEN="ja" ;; --standard) MODUS="standard" ;; --erweitert) MODUS="erweitert" ;; --aktualisieren) NUR_AKTUALISIEREN="ja" ;; --zweig) [ $# -ge 2 ] || abbrechen "--zweig braucht einen Namen." ZWEIG="$2" shift ;; --hilfe | --help | -h) hilfe_zeigen exit 0 ;; *) trap - ERR fehlertext "Unbekannte Option: $1" printf '\n' hilfe_zeigen exit 2 ;; esac shift done } # --------------------------------------------------------------------------- # Umgebung # --------------------------------------------------------------------------- umgebung_pruefen() { kopf "Umgebung" if [ "$TROCKEN" = "ja" ]; then notiz "Trockenlauf: Prüfung auf root und auf Proxmox VE 8/9 übersprungen." notiz "Es wird nichts angelegt, nichts geladen, nichts gestartet." return 0 fi [ "$(id -u)" -eq 0 ] || abbrechen "Dieses Skript muss als root auf der Proxmox-Shell laufen." command -v pveversion >/dev/null 2>&1 || abbrechen \ "pveversion fehlt — das ist kein Proxmox-VE-Host. Für Debian ohne Proxmox gibt es deploy/install.sh." command -v pct >/dev/null 2>&1 || abbrechen "pct fehlt — Proxmox VE ist unvollständig installiert." command -v pveam >/dev/null 2>&1 || abbrechen "pveam fehlt — Proxmox VE ist unvollständig installiert." command -v pvesm >/dev/null 2>&1 || abbrechen "pvesm fehlt — Proxmox VE ist unvollständig installiert." # pveversion liefert zum Beispiel: pve-manager/8.4.1/2a5fa54a8503f96d local voll haupt voll="$(pveversion | sed -n 's#^pve-manager/\([0-9][0-9.]*\)/.*#\1#p' | head -n 1)" haupt="${voll%%.*}" case "$haupt" in 8 | 9) erfolg "Proxmox VE $voll" ;; "") abbrechen "Die Version von Proxmox VE ist nicht erkennbar (pveversion sagt: $(pveversion | head -n 1))." ;; *) abbrechen "Proxmox VE $voll wird nicht unterstützt — dieses Skript ist für PVE 8 und 9 gebaut." ;; esac erfolg "Als root auf $(hostname)" } # --------------------------------------------------------------------------- # Dialog # --------------------------------------------------------------------------- # Liest einen Wert per whiptail in die genannte Variable, deren bisheriger # Inhalt die Vorgabe ist. Der Weg über eine Namensreferenz statt über eine # Kommandosubstitution ist Absicht: bricht jemand den Dialog ab, muss das # ganze Skript enden, und ein „exit“ in einer Kommandosubstitution beendet nur # die Subshell — der Wert wäre leer und das Skript liefe weiter. frage_wert() { local -n ziel="$1" local titel="$2" wert if ! wert="$(whiptail --title "Laufzettel einrichten" \ --inputbox "$titel" 9 64 "$ziel" 3>&1 1>&2 2>&3)"; then abbrechen "Abgebrochen. Es wurde nichts angelegt." fi [ -n "$wert" ] || wert="$ziel" ziel="$wert" } zahl_pruefen() { case "$2" in '' | *[!0-9]*) abbrechen "$1 muss eine Zahl sein, war aber $ANF_AUF$2$ANF_ZU." ;; esac } dialog_fuehren() { kopf "Werte" # Ohne Terminal oder ohne whiptail gibt es keinen Dialog. Dann gilt der # Standardweg, und das wird ausdrücklich gesagt — auch wenn --erweitert # verlangt war, denn fragen kann das Skript dann niemanden. if [ "$MODUS" != "standard" ]; then if [ ! -t 0 ]; then MODUS="standard" notiz "Kein Terminal an der Standardeingabe — es gelten die Standardwerte." elif ! command -v whiptail >/dev/null 2>&1; then MODUS="standard" notiz "whiptail ist nicht installiert — es gelten die Standardwerte." fi fi if [ "$MODUS" = "frage" ]; then local wahl if ! wahl="$(whiptail --title "Laufzettel einrichten" \ --menu "Wie soll der Container aussehen?" 13 68 2 \ "standard" "$STD_KERNE Kerne, $((STD_RAM / 1024)) GB RAM, $STD_PLATTE GB Platte, DHCP" \ "erweitert" "Jeden Wert selbst setzen" 3>&1 1>&2 2>&3)"; then abbrechen "Abgebrochen. Es wurde nichts angelegt." fi MODUS="$wahl" fi # Die nächste freie CTID kommt immer aus dem Cluster, im Erweitert-Weg nur # als Vorbelegung. pve_lesen "100" "pvesh get /cluster/nextid" naechste_ctid_holen CTID="$ANTWORT" [ -n "$CTID" ] || abbrechen "pvesh get /cluster/nextid hat keine CTID geliefert." if [ "$MODUS" = "erweitert" ]; then frage_wert CTID "CTID des neuen Containers" frage_wert HOSTNAME_CT "Hostname" frage_wert DEBIAN "Debian-Hauptversion (13 ist erprobt)" frage_wert KERNE "Kerne" frage_wert RAM "Arbeitsspeicher in MB" frage_wert SWAP "Swap in MB" frage_wert PLATTE "Plattengröße in GB" frage_wert BRUECKE "Netzwerkbrücke" frage_wert IP_WUNSCH "IP-Adresse: dhcp oder zum Beispiel 192.168.1.50/24" if [ "$IP_WUNSCH" != "dhcp" ]; then frage_wert GATEWAY "Gateway zur festen IP-Adresse" [ -n "$GATEWAY" ] || abbrechen "Zu einer festen IP-Adresse gehört ein Gateway." fi frage_wert SPEICHER_ROOT "Speicher für das Wurzeldateisystem" frage_wert SPEICHER_VORLAGE "Speicher für die Debian-Vorlage" if whiptail --title "Laufzettel einrichten" \ --yesno "Unprivilegierten Container anlegen?\n\nEmpfohlen ist ja." 10 60; then UNPRIV="1" else UNPRIV="0" fi fi zahl_pruefen "Die CTID" "$CTID" zahl_pruefen "Die Zahl der Kerne" "$KERNE" zahl_pruefen "Der Arbeitsspeicher" "$RAM" zahl_pruefen "Die Plattengröße" "$PLATTE" zahl_pruefen "Der Swap" "$SWAP" zahl_pruefen "Die Debian-Version" "$DEBIAN" [ "$CTID" -ge 100 ] || abbrechen "Die CTID muss mindestens 100 sein, war aber $CTID." [ "$KERNE" -gt 0 ] || abbrechen "Der Container braucht mindestens einen Kern." [ "$RAM" -ge 1024 ] || abbrechen "Unter 1024 MB reicht der Speicher für ${ANF_AUF}next build$ANF_ZU nicht." [ "$PLATTE" -ge 8 ] || abbrechen "Unter 8 GB reicht die Platte für Node, pnpm-Store und PostgreSQL nicht." [ -n "$SPEICHER_ROOT" ] || abbrechen "Ohne Speicher für das Wurzeldateisystem geht es nicht." [ -n "$SPEICHER_VORLAGE" ] || abbrechen "Ohne Speicher für die Vorlage geht es nicht." info "CTID $CTID · $HOSTNAME_CT · Debian $DEBIAN · $KERNE Kerne · $RAM MB RAM · $PLATTE GB Platte" if [ "$UNPRIV" = "1" ]; then info "unprivilegiert · $BRUECKE · $IP_WUNSCH" else info "privilegiert · $BRUECKE · $IP_WUNSCH" fi } # Alle Leser enden auf „|| true“: ein leeres Ergebnis soll beim Aufrufer eine # verständliche Meldung auslösen und nicht den ERR-Trap mit einer Zeilennummer, # die niemandem weiterhilft. Ohne das würde schon ein grep ohne Treffer wegen # pipefail als Programmfehler durchschlagen. naechste_ctid_holen() { # pvesh gibt die Zahl je nach Ausgabeformat in Anführungszeichen zurück. pvesh get /cluster/nextid 2>/dev/null | tr -d '"[:space:]' || true } # --------------------------------------------------------------------------- # Speicher # --------------------------------------------------------------------------- # Erster aktive Speicher, der den gewünschten Inhalt aufnehmen kann. Die # Reihenfolge bevorzugt, was auf einem Standard-PVE üblich ist. speicher_suchen() { local inhalt="$1" liste bevorzugt liste="$(pvesm status --content "$inhalt" 2>/dev/null | awk 'NR > 1 && $3 == "active" { print $1 }' || true)" for bevorzugt in local-lvm local-zfs local-btrfs local; do if printf '%s\n' "$liste" | grep -qx -- "$bevorzugt"; then printf '%s' "$bevorzugt" return 0 fi done printf '%s\n' "$liste" | head -n 1 || true } speicher_waehlen() { kopf "Speicher" pve_lesen "local-lvm" "pvesm status --content rootdir" speicher_suchen rootdir SPEICHER_ROOT="$ANTWORT" [ -n "$SPEICHER_ROOT" ] || abbrechen \ "Kein aktiver Speicher mit Inhalt ${ANF_AUF}rootdir$ANF_ZU gefunden — ohne den kann kein Container angelegt werden." pve_lesen "local" "pvesm status --content vztmpl" speicher_suchen vztmpl SPEICHER_VORLAGE="$ANTWORT" [ -n "$SPEICHER_VORLAGE" ] || abbrechen \ "Kein aktiver Speicher mit Inhalt ${ANF_AUF}vztmpl$ANF_ZU gefunden — dort müsste die Debian-Vorlage liegen." erfolg "Wurzeldateisystem auf $SPEICHER_ROOT, Vorlage auf $SPEICHER_VORLAGE" } # --------------------------------------------------------------------------- # Debian-Vorlage # --------------------------------------------------------------------------- # Neueste passende Vorlage aus der Liste von pveam. Der Dateiname ändert sich # mit jedem Punktrelease (debian-13-standard_13.1-1_amd64.tar.zst), darum wird # er gesucht und nicht festgeschrieben. vorlage_suchen() { pveam available --section system 2>/dev/null | awk '{ print $2 }' | grep -E "^debian-$1-standard_" | sort -V | tail -n 1 || true } vorlage_vorhanden() { if pveam list "$1" 2>/dev/null | awk '{ print $1 }' | grep -qF -- "vztmpl/$2"; then printf 'ja' else printf 'nein' fi } vorlage_besorgen() { kopf "Debian-Vorlage" spinner_an "Vorlagenliste auffrischen" pve_ausfuehren pveam update >/dev/null 2>&1 || warnung "pveam update ist fehlgeschlagen, es gilt die zwischengespeicherte Liste." spinner_aus pve_lesen "debian-$DEBIAN-standard_$DEBIAN.1-1_amd64.tar.zst" \ "pveam available --section system" vorlage_suchen "$DEBIAN" VORLAGE="$ANTWORT" [ -n "$VORLAGE" ] || abbrechen \ "In ${ANF_AUF}pveam available$ANF_ZU steht keine Vorlage debian-$DEBIAN-standard. Andere Debian-Version wählen oder die Vorlage von Hand laden." pve_lesen "nein" "pveam list $SPEICHER_VORLAGE" vorlage_vorhanden "$SPEICHER_VORLAGE" "$VORLAGE" if [ "$ANTWORT" = "ja" ]; then erfolg "Vorlage $VORLAGE liegt schon auf $SPEICHER_VORLAGE" else spinner_an "Vorlage $VORLAGE laden" pve_ausfuehren pveam download "$SPEICHER_VORLAGE" "$VORLAGE" spinner_aus erfolg "Vorlage $VORLAGE geladen" fi } # --------------------------------------------------------------------------- # Container anlegen und starten # --------------------------------------------------------------------------- container_anlegen() { kopf "Container anlegen" # Ohne firewall=…: auf der Befehlszeile ist die Firewall am Gerät ohnehin aus, # und der Aufruf soll genau das zeigen, was er tut. local netz="name=eth0,bridge=$BRUECKE" if [ "$IP_WUNSCH" = "dhcp" ]; then netz="$netz,ip=dhcp" else netz="$netz,ip=$IP_WUNSCH,gw=$GATEWAY" fi # nesting=1 ist hier keine Bequemlichkeit: die systemd-Unit aus install.sh # arbeitet mit ProtectSystem=strict und PrivateTmp=yes, und diese Sandkästen # brauchen im unprivilegierten LXC eigene Mount-Namensräume (#019). # Ab hier darf das Aufräumen den Container wieder entfernen. HALB_ANGELEGT="ja" spinner_an "Container $CTID anlegen" pve_ausfuehren pct create "$CTID" "$SPEICHER_VORLAGE:vztmpl/$VORLAGE" \ --hostname "$HOSTNAME_CT" \ --ostype debian \ --arch amd64 \ --cores "$KERNE" \ --memory "$RAM" \ --swap "$SWAP" \ --rootfs "$SPEICHER_ROOT:$PLATTE" \ --net0 "$netz" \ --unprivileged "$UNPRIV" \ --features nesting=1 \ --onboot 1 \ --timezone host \ --description "Laufzettel — Checklisten für wiederkehrende IT-Arbeiten. Angelegt von deploy/proxmox/$SKRIPTNAME." spinner_aus erfolg "Container $CTID angelegt" spinner_an "Container starten" pve_ausfuehren pct start "$CTID" spinner_aus erfolg "Container $CTID gestartet" } # Wartet, bis im Container Befehle laufen und Namen aufgelöst werden. Ohne das # scheitert das erste apt-get mit einem irritierenden DNS-Fehler. netz_abwarten() { kopf "Netz" if [ "$TROCKEN" = "ja" ]; then trocken_zeigen pct exec "$CTID" -- test -d /run/systemd/system trocken_zeigen pct exec "$CTID" -- ip -4 -o addr show scope global trocken_zeigen pct exec "$CTID" -- getent hosts deb.debian.org CT_IP="192.0.2.42" notiz "Im Trockenlauf wird nicht gewartet; als Adresse steht hier die Beispiel-IP $CT_IP." return 0 fi local versuch spinner_an "Auf den Start des Containers warten" for ((versuch = 1; versuch <= 60; versuch++)); do if pct exec "$CTID" -- test -d /run/systemd/system >/dev/null 2>&1; then break fi if [ "$versuch" -eq 60 ]; then spinner_aus abbrechen_und_behalten "Container $CTID ist nach 120 Sekunden nicht betriebsbereit. Er bleibt stehen, nachsehen mit: pct enter $CTID" fi sleep 2 done spinner_aus spinner_an "Auf IP-Adresse und Namensauflösung warten" for ((versuch = 1; versuch <= 60; versuch++)); do CT_IP="$(pct exec "$CTID" -- ip -4 -o addr show scope global 2>/dev/null | awk 'NR == 1 { split($4, teile, "/"); print teile[1] }')" if [ -n "$CT_IP" ] && pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then break fi if [ "$versuch" -eq 60 ]; then spinner_aus abbrechen_und_behalten "Container $CTID hat nach 120 Sekunden kein brauchbares Netz. Brücke $BRUECKE und DHCP prüfen; er bleibt stehen, nachsehen mit: pct enter $CTID" fi sleep 2 done spinner_aus erfolg "Container $CTID erreichbar unter $CT_IP" } # --------------------------------------------------------------------------- # Laufzettel einrichten # --------------------------------------------------------------------------- # install.sh wird als einzelne Datei per curl geholt, das Repo wird hier nicht # geklont: install.sh legt den Quellbaum selbst nach /opt/laufzettel und braucht # ihn dort auch für den Aktualisierungsweg (git pull). Ein zweiter Klon daneben # wäre eine zweite Wahrheit. Geholt wird von derselben Adresse, von der auch # dieses Skript kam — wer einen eigenen Spiegel nutzt, bekommt beide Teile aus # demselben Spiegel. anwendung_einrichten() { local zweck="$1" # einrichten | aktualisieren kopf "Laufzettel $zweck" # Ab hier wird der Container nicht mehr weggeräumt: scheitert install.sh, will # man das Protokoll darin lesen und nicht vor einem verschwundenen Container # stehen. Jeder Fehler ab hier nennt darum die CTID. HALB_ANGELEGT="nein" local roh_url="$REPO_ROH/$ZWEIG/deploy/install.sh" spinner_an "Paketliste und Grundwerkzeuge" if ! pve_ausfuehren pct exec "$CTID" -- bash -c \ 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq curl git ca-certificates'; then spinner_aus abbrechen "apt-get ist im Container $CTID fehlgeschlagen. Nachsehen mit: pct enter $CTID" fi spinner_aus erfolg "curl, git und ca-certificates sind vorhanden" # Erst herunterladen, dann ausführen: „curl | bash“ würde einen abgebrochenen # Download stillschweigend als halbes Skript starten. spinner_an "install.sh holen" if ! pve_ausfuehren pct exec "$CTID" -- bash -c \ "curl -fsSL --retry 3 -o /root/laufzettel-install.sh '$roh_url' && test -s /root/laufzettel-install.sh"; then spinner_aus abbrechen "install.sh war unter $roh_url nicht zu holen. Zweig richtig geschrieben? Kommt der Container an den Gitea-Server?" fi spinner_aus erfolg "install.sh aus Zweig $ZWEIG geholt" # APP_URL und BIND_ADDR stehen erst hier fest, weil erst jetzt die IP bekannt # ist. An APP_URL hängen die QR-Codes und die OIDC-Rückrufadresse. Bei einer # Aktualisierung werden beide bewusst nicht mitgegeben: wer einen Reverse # Proxy davorgesetzt und APP_URL auf den richtigen Namen gestellt hat, soll # den nicht beim nächsten Aufruf gegen eine IP-Adresse getauscht bekommen. local umgebung="LAUFZETTEL_REPO='$REPO_GIT' LAUFZETTEL_ZWEIG='$ZWEIG'" umgebung="$umgebung LAUFZETTEL_PASSWORT_DATEI='$PASSWORT_DATEI'" if [ "$zweck" = "einrichten" ]; then umgebung="APP_URL='http://$CT_IP:3000' BIND_ADDR=0.0.0.0 $umgebung" fi # Hier läuft ausdrücklich kein Spinner: install.sh meldet seine neun Schritte # selbst, und das dauert Minuten. Ein Spinner würde diese Meldungen # überschreiben und wäre das Gegenteil von Fortschrittsanzeige. info "install.sh läuft: PostgreSQL, Node, Build, Migration, Seed. Das dauert einige Minuten." notiz "Ab hier spricht install.sh selbst." printf '\n' if ! pve_ausfuehren pct exec "$CTID" -- bash -c "$umgebung bash /root/laufzettel-install.sh"; then abbrechen "install.sh ist im Container $CTID fehlgeschlagen. Der Container bleibt stehen: pct enter $CTID, dann journalctl -u laufzettel -n 50" fi printf '\n' erfolg "install.sh ist durchgelaufen" } # Fragt von außen nach, ob der Dienst antwortet. install.sh prüft dasselbe # selbst, aber nur auf seiner Bindeadresse im Container — erreichbar aus dem Netz # ist damit noch nichts bewiesen. Ein Befund bricht nicht ab: die Installation # steht dann schon, und die Adresse gehört auf den Abschlussbildschirm. gesundheit_pruefen() { kopf "Erreichbarkeit" local adresse="http://$CT_IP:3000/api/gesundheit" if [ "$TROCKEN" = "ja" ]; then trocken_text "curl -fsS $adresse" notiz "Im Trockenlauf wird nicht gefragt." return 0 fi local versuch antwort="" spinner_an "Auf Antwort von $adresse warten" for ((versuch = 1; versuch <= 30; versuch++)); do antwort="$(curl -fsS --max-time 5 "$adresse" 2>/dev/null || true)" case "$antwort" in *'"ok"'*) break ;; esac antwort="" sleep 2 done spinner_aus if [ -n "$antwort" ]; then erfolg "$adresse antwortet: $antwort" else warnung "$adresse antwortet nicht. Dienst: pct exec $CTID -- systemctl status laufzettel · Protokoll: pct exec $CTID -- journalctl -u laufzettel -n 50" fi } # --------------------------------------------------------------------------- # Abschluss # --------------------------------------------------------------------------- # Wie das Passwort hierher kommt: install.sh zeigt das beim ersten Lauf erzeugte # Administrator-Passwort genau einmal auf seiner eigenen Ausgabe an — und die # läuft hier hinter einem Spinner durch pct exec vorbei. In die Umgebungsdatei # schreibt install.sh es ausdrücklich nicht, in der Datenbank steht nur der # argon2-Hash. Für genau diesen Fall hat install.sh --passwort-datei # beziehungsweise LAUFZETTEL_PASSWORT_DATEI: eine Datei mit 0600 und root, aus # der ein maschineller Aufrufer das Passwort abliest und die er danach löscht. # Das ist der Weg hier — lesen, einmal anzeigen, sofort wegwerfen. Die Ausgabe # von install.sh mitzuschneiden und zu durchsuchen wäre von deren # Formulierungen abhängig und würde das Passwort zusätzlich durch Pipes und # Protokolldateien schleifen. # # Die Kennung dagegen steht in der Umgebungsdatei und wird von dort gelesen. erstpasswort_lesen() { pct exec "$CTID" -- sh -c \ "sed -n 's/^SEED_ADMIN_PASSWORD=//p' '$PASSWORT_DATEI' 2>/dev/null | tail -n 1" 2>/dev/null || true } env_wert_lesen() { pct exec "$CTID" -- sh -c "sed -n 's/^$1=//p' '$ENV_DATEI' | tail -n 1" 2>/dev/null | tr -d '\r' | sed -e 's/^"//' -e 's/"$//' || true } abschluss_zeigen() { local kennung passwort text kopf "Fertig" pve_lesen "admin@firma.example" "pct exec $CTID -- grep SEED_ADMIN_EMAIL $ENV_DATEI" \ env_wert_lesen SEED_ADMIN_EMAIL kennung="$ANTWORT" [ -n "$kennung" ] || kennung="steht in $ENV_DATEI" if [ "$TROCKEN" = "ja" ]; then trocken_text "pct exec $CTID -- cat $PASSWORT_DATEI" passwort="(im Trockenlauf gibt es keines)" else pve_lesen "" "pct exec $CTID -- cat $PASSWORT_DATEI" erstpasswort_lesen passwort="$ANTWORT" fi # Die Leihgabe wird zurückgegeben, gelesen oder nicht: das Passwort soll nicht # im Klartext im Container liegen bleiben. pve_ausfuehren pct exec "$CTID" -- rm -f "$PASSWORT_DATEI" || true if [ -z "$passwort" ]; then passwort="unverändert (das Konto bestand schon)" fi text="$( cat </dev/null 2>&1; then whiptail --title "Laufzettel — fertig" --msgbox "$text" 30 78 fi printf '%s\n' "$text" | sed 's/^/ /' printf '\n' } # --------------------------------------------------------------------------- # Vorhandenen Container erkennen # --------------------------------------------------------------------------- container_status() { pct status "$1" 2>/dev/null | awk '{ print $2 }' || true } # „pct list“ gibt VMID, Status, gegebenenfalls Lock und den Namen aus. Der Name # ist die letzte Spalte, und Hostnamen enthalten keine Leerzeichen — darum $NF. container_mit_hostname() { pct list 2>/dev/null | awk -v gesucht="$1" 'NR > 1 && $NF == gesucht { print $1 }' | head -n 1 || true } # Liefert 0, wenn der vorhandene Container aktualisiert werden soll. aktualisierung_anbieten() { local id="$1" if [ "$NUR_AKTUALISIEREN" = "ja" ]; then notiz "--aktualisieren war gesetzt — Container $id wird aktualisiert." return 0 fi if [ ! -t 0 ] || ! command -v whiptail >/dev/null 2>&1; then notiz "Kein Dialog möglich — Container $id wird aktualisiert." return 0 fi whiptail --title "Laufzettel ist schon da" \ --yes-button "Aktualisieren" --no-button "Abbrechen" \ --yesno "Container $id trägt schon den Hostnamen $ANF_AUF$STD_HOSTNAME$ANF_ZU. Aktualisieren heißt: install.sh erneut ausführen — git pull, Build, Migration, Dienst neu starten. Geheimnisse und Daten bleiben unberührt. Einen zweiten Laufzettel legt dieses Skript nicht daneben; dafür im Erweitert-Weg einen anderen Hostnamen wählen." 17 72 } # --------------------------------------------------------------------------- # Ablauf # --------------------------------------------------------------------------- haupt() { argumente_lesen "$@" printf '\n%sLaufzettel — Proxmox-LXC%s\n' "$F_FETT" "$F_AUS" if [ "$TROCKEN" = "ja" ]; then printf '%sTrockenlauf: es wird nur gezeigt, was geschehen würde.%s\n' "$F_GELB" "$F_AUS" fi umgebung_pruefen # Vor allem anderen: gibt es Laufzettel hier schon? kopf "Vorhandener Container" # Im Trockenlauf gibt es kein pct list. Mit --aktualisieren wird darum eine # vorhandene CTID angenommen, sonst keine — so lässt sich jeder der beiden # Wege trocken durchspielen. local angenommen="" [ "$NUR_AKTUALISIEREN" = "ja" ] && angenommen="100" pve_lesen "$angenommen" "pct list" container_mit_hostname "$STD_HOSTNAME" local vorhanden="$ANTWORT" if [ -z "$vorhanden" ] && [ "$NUR_AKTUALISIEREN" = "ja" ]; then abbrechen "Kein Container mit dem Hostnamen $STD_HOSTNAME — mit --aktualisieren wird keiner angelegt." fi if [ -z "$vorhanden" ]; then erfolg "Kein Container mit dem Hostnamen $STD_HOSTNAME — es wird ein neuer angelegt." speicher_waehlen dialog_fuehren vorlage_besorgen container_anlegen netz_abwarten anwendung_einrichten "einrichten" gesundheit_pruefen abschluss_zeigen return 0 fi info "Container $vorhanden trägt den Hostnamen $STD_HOSTNAME." if ! aktualisierung_anbieten "$vorhanden"; then abbrechen "Abgebrochen. Es wurde nichts geändert." fi CTID="$vorhanden" # Der Container kann gestoppt sein. Erst fragen, dann starten — „pct start“ # auf einem laufenden Container meldet nur einen Fehler, den niemand braucht. pve_lesen "running" "pct status $CTID" container_status "$CTID" if [ "$ANTWORT" = "running" ]; then info "Container $CTID läuft." else spinner_an "Container $CTID starten" pve_ausfuehren pct start "$CTID" spinner_aus fi netz_abwarten anwendung_einrichten "aktualisieren" gesundheit_pruefen abschluss_zeigen } haupt "$@"