Vorschau·Automatisierung/Material
Automatisierung

Material zum Kurs

Was Sie mitnehmen: die Skripte und Vorlagen, mit denen gearbeitet wird, die Prüfliste des ganzen Kurses und alle Befehle auf einem Blatt. Alles hier wird aus dem Kurs erzeugt — es ist also immer der Stand, den Sie gerade lernen.

Die Abnahme

Die Prüfliste des ganzen Kurses

Alle Prüfpunkte am Stück. Mit Zugang haken Sie hier ab — der Stand ist derselbe wie in den Phasen.

Station 01 · EinrichtenBevor es weitergeht
Station 01 · EinrichtenPrüfliste Absicherung
Station 01 · EinrichtenPrüfliste Sicherung
Station 01 · EinrichtenPrüfliste Inbetriebnahme
Nachschlagen

Alle Befehle des Kurses

In der Reihenfolge, in der sie im Kurs vorkommen.

Station 00so klingt eine brauchbare Beschreibung
Auslöser:  Eine Anfrage über das Kontaktformular geht ein.
Schritte:  Pflichtfelder prüfen · Vorgangsschlüssel bilden · in der Datenbank ablegen ·
           Vertrieb benachrichtigen · dem Absender bestätigen.
Ergebnis:  Abgelegt und beide informiert — oder in der Liste "offene Fälle",
           mit dem Grund dabei.
Station 00unterlagen/ablauf-steckbrief.md
# Steckbrief: <Name des Ablaufs>

> Eine Seite, ausgefüllt **bevor** gebaut wird. Wer sie nicht ausfüllen kann,
> hat noch keinen Ablauf, sondern einen Wunsch. Ausgefüllt gehört sie neben den
> Ablauf ins Repository — sie ist die Beschreibung, die der Nachfolger liest.

| | |
|---|---|
| **Name** | z. B. `anfrage-kontaktformular` |
| **Zuständige Person** | Name, nicht Abteilung |
| **Vertretung** | wer im Urlaub einspringt |
| **Angelegt am / von** | |
| **Fassung** | 1 |

## 1 · Auslöser — genau einer

- [ ] **Ereignis:** _welches, aus welchem System_
- [ ] **Uhrzeit:** _wann genau, an welchen Tagen_
- [ ] **Zuruf:** _wer drückt den Knopf_

**Wie oft kommt er vor?** ca. ____ mal je Woche

## 2 · Schritte — in der richtigen Reihenfolge

| Nr | Was geschieht | Umkehrbar? | Fremdsystem |
|----|---------------|------------|-------------|
| 1 | | ja / nein | |
| 2 | | ja / nein | |
| 3 | | ja / nein | |
| 4 | | ja / nein | |

> **Regel:** alles Umkehrbare zuerst, alles Endgültige (Mail, Zahlung,
> Fremdsystem) zuletzt. Dann ist jeder Abbruch folgenlos.

## 3 · Ergebnis — beide Ausgänge

- **Durchgelaufen heißt:** _woran sieht man das, wo steht es_
- **Vorgelegt heißt:** _wohin geht der Zweifelsfall, mit welchem Grund_
- **Wer bearbeitet die vorgelegten Fälle?**

## 4 · Der Vorgangsschlüssel

- **Woraus besteht er?** _z. B. Bestellnummer, oder E-Mail + gerundeter Zeitpunkt_
- [ ] Er kommt aus dem Vorgang selbst — **keine Zufallszahl**
- [ ] Ein zweiter Durchlauf mit demselben Vorgang legt nichts doppelt an

## 5 · Wenn es schiefgeht

- **Fehler-Ablauf eingetragen?** ja / nein
- **Meldung geht an:** _Person_
- **Was geschieht bis zur Behebung von Hand?** _der Weg, den es vorher gab_

## 6 · Stillstand

- **Frist für das Lebenszeichen:** ____ (minütlich → 15 min · stündlich → 3 h · täglich → bis 9:00)
- **Wer bekommt die Stillstandsmeldung?**

## 7 · Daten

- **Welche personenbezogenen Daten laufen durch?**
- **Welche Fremdsysteme sehen sie?** _und liegt dafür ein Vertrag vor?_
- **Wie lange bleiben die Daten in den Ausführungen von n8n stehen?** ____ Tage
Station 01auf dem eigenen Rechner
scp -r automatisierung betreiber@apps.example.de:~
Station 01auf dem Server
sudo bash automatisierung/n8n-aufsetzen.sh \
  --domain ablauf.example.de --email ich@example.de --nur-pruefen
Station 01auf dem Server
sudo bash automatisierung/n8n-aufsetzen.sh \
  --domain ablauf.example.de --email ich@example.de
Station 01n8n-aufsetzen.sh
#!/usr/bin/env bash
#
# n8n-aufsetzen.sh — aus einem Server mit Docker wird eine Umgebung,
# in der Abläufe laufen können.
#
#   Vorher:  ein Server nach bootstrap.sh (Docker, Traefik über Coolify, Domain)
#   Nachher: n8n und seine Datenbank als Container, hinter HTTPS, mit
#            gesichertem Verschlüsselungsschlüssel und aufgeräumten Läufen
#
# Das Skript darf mehrfach laufen. Jeder Schritt prüft erst, ob er schon
# erledigt ist. Ein zweiter Lauf ändert nichts und ergänzt, was fehlt.
#
#   bash n8n-aufsetzen.sh --domain ablauf.example.de --email ich@example.de --nur-pruefen
#   bash n8n-aufsetzen.sh --domain ablauf.example.de --email ich@example.de
#
# Kurs 3 der DevEnv-Lernstrecke, Station 01. Was hier passiert, wird gezeigt
# und benannt — auswendig lernen muss es niemand.

set -Eeuo pipefail

# =====================================================================
# Einstellungen
# =====================================================================
DOMAIN=""
EMAIL=""
AUSFUEHREN=1                 # 0 = Trockenlauf: nur zeigen, was geschähe
VERZEICHNIS="/opt/n8n"
NETZ="coolify"               # das Docker-Netz, in dem Traefik lauscht
ZEITZONE="Europe/Berlin"
LAUFE_TAGE=14                # so lange werden Ausführungsdaten aufbewahrt
BILD="docker.n8n.io/n8nio/n8n:1.114.0"
DB_BILD="postgres:16-alpine"
UMSCHLAG="/root/n8n-notfallumschlag.tar.gz"

# =====================================================================
# Ausgabe — vier Zeilen je Problem: Ursache, Folge, nächster Schritt, Fundstelle
# =====================================================================
if [[ -t 1 ]]; then
  F_TITEL=$'\e[1m'; F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_FEHLER=$'\e[31m'; F_LEISE=$'\e[2m'; F_AUS=$'\e[0m'
else
  F_TITEL=""; F_OK=""; F_WARN=""; F_FEHLER=""; F_LEISE=""; F_AUS=""
fi
schritt()  { printf '\n%s▸ %s%s\n' "$F_TITEL" "$1" "$F_AUS"; }
ok()       { printf '  %s✓%s %s\n' "$F_OK" "$F_AUS" "$1"; }
info()     { printf '  %s·%s %s\n' "$F_LEISE" "$F_AUS" "$1"; }
warnung()  { printf '  %s!%s %s\n' "$F_WARN" "$F_AUS" "$1"; }
tue()      { if (( AUSFUEHREN )); then eval "$1"; else printf '  %s… würde tun:%s %s\n' "$F_LEISE" "$F_AUS" "$1"; fi; }

abbruch() {
  printf '\n%s✗ Abgebrochen%s\n' "$F_FEHLER" "$F_AUS" >&2
  printf '  Ursache:          %s\n' "$1" >&2
  printf '  Folge:            %s\n' "$2" >&2
  printf '  Nächster Schritt: %s\n' "$3" >&2
  printf '  Fundstelle:       %s\n\n' "${4:-Kurs 3, Station 01}" >&2
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --domain)      DOMAIN="${2:?--domain braucht einen Wert}"; shift 2 ;;
    --email)       EMAIL="${2:?--email braucht einen Wert}"; shift 2 ;;
    --verzeichnis) VERZEICHNIS="${2:?}"; shift 2 ;;
    --netz)        NETZ="${2:?}"; shift 2 ;;
    --nur-pruefen) AUSFUEHREN=0; shift ;;
    -h|--hilfe|--help)
      sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) abbruch "Unbekannte Angabe: $1" "Nichts wurde geändert." "bash $0 --hilfe" ;;
  esac
done

[[ -n "$DOMAIN" ]] || abbruch "Keine Domain angegeben." \
  "Ohne Domain gibt es kein Zertifikat und keine Webhook-Adresse." \
  "bash $0 --domain ablauf.example.de --email ich@example.de"

# =====================================================================
# 1 · Vorabprüfung — was da sein muss, bevor irgendetwas entsteht
# =====================================================================
schritt "1 · Vorabprüfung"
if (( EUID != 0 )); then
  if (( AUSFUEHREN )); then
    abbruch "Nicht als root gestartet." "Verzeichnisse unter /opt lassen sich nicht anlegen." "Mit sudo ausführen: sudo bash $0 …"
  else
    warnung "Nicht als root — für den Trockenlauf genügt das, für den scharfen Lauf nicht"
  fi
else
  ok "Als root gestartet"
fi

command -v docker >/dev/null || abbruch "Docker fehlt." \
  "n8n läuft als Container — ohne Docker geht nichts." \
  "Erst bootstrap.sh aus Kurs 1 laufen lassen." "Kurs 1, Station 03"
ok "Docker vorhanden ($(docker --version | cut -d' ' -f3 | tr -d ,))"

if docker network inspect "$NETZ" >/dev/null 2>&1; then
  ok "Docker-Netz „${NETZ}“ vorhanden"
else
  warnung "Docker-Netz „${NETZ}“ fehlt — ohne Traefik im selben Netz gibt es kein Zertifikat"
  info "Anderes Netz: --netz <name>. Ohne Coolify: Traefik selbst betreiben (siehe README)."
fi

EIGENE_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || echo '')"
aufloesen() {
  if   command -v dig    >/dev/null; then dig +short A "$1" 2>/dev/null | tail -1
  elif command -v getent >/dev/null; then getent hosts "$1" 2>/dev/null | awk '{print $1}' | tail -1
  elif command -v host   >/dev/null; then host "$1" 2>/dev/null | awk '/has address/{print $NF}' | tail -1
  fi
}
ZIEL="$(aufloesen "$DOMAIN" || true)"
if [[ -z "$ZIEL" ]]; then
  warnung "${DOMAIN} zeigt auf nichts — A-Eintrag setzen, sonst kommt kein Zertifikat"
elif [[ -n "$EIGENE_IP" && "$ZIEL" != "$EIGENE_IP" ]]; then
  warnung "${DOMAIN} zeigt auf ${ZIEL}, dieser Server ist ${EIGENE_IP}"
else
  ok "${DOMAIN} zeigt hierher"
fi

FREI_MB="$(df -Pm / | awk 'NR==2{print $4}')"
(( FREI_MB > 2048 )) || abbruch "Weniger als 2 GB frei (${FREI_MB} MB)." \
  "Der Abbild-Download bricht mittendrin ab." "Platz schaffen: docker system prune -a"
ok "Platz frei: ${FREI_MB} MB"

# =====================================================================
# 2 · Verzeichnis und Geheimnisse
# =====================================================================
schritt "2 · Verzeichnis und Geheimnisse"
tue "mkdir -p '${VERZEICHNIS}/daten' '${VERZEICHNIS}/db' '${VERZEICHNIS}/ablaeufe'"
tue "chmod 700 '${VERZEICHNIS}'"

UMGEBUNG="${VERZEICHNIS}/.env"
if [[ -f "$UMGEBUNG" ]]; then
  ok "Umgebungsdatei besteht schon — Geheimnisse bleiben unverändert"
  info "Das ist Absicht: Ein neuer Verschlüsselungsschlüssel macht alle hinterlegten Zugänge unlesbar."
else
  SCHLUESSEL="$(openssl rand -hex 32)"
  DB_KENNWORT="$(openssl rand -base64 30 | tr -d '/+=' | cut -c1-28)"
  WEBHOOK_TOKEN="$(openssl rand -hex 24)"
  if (( AUSFUEHREN )); then
    umask 077
    cat > "$UMGEBUNG" <<UMG
# n8n — Umgebung. Diese Datei ist das Geheimnis dieser Installation.
# Der Verschlüsselungsschlüssel entschlüsselt ALLE hinterlegten Zugänge.
# Ohne ihn ist jede Sicherung wertlos. Er gehört zusätzlich in den
# Passwortspeicher des Unternehmens — nicht nur hierher.
N8N_ENCRYPTION_KEY=${SCHLUESSEL}

# Wo n8n erreichbar ist. WEBHOOK_URL muss stimmen, sonst tragen die
# Auslöser-Knoten eine falsche Adresse ein.
DOMAIN=${DOMAIN}
N8N_HOST=${DOMAIN}
N8N_PORT=5678
N8N_PROTOCOL=https
WEBHOOK_URL=https://${DOMAIN}/
N8N_EDITOR_BASE_URL=https://${DOMAIN}/

# Datenbank
POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=${DB_KENNWORT}

# Vorschlag für das Geheimnis des Beispiel-Auslösers. Es wird NICHT von n8n
# gelesen: Sie tragen den Wert beim Anlegen des Zugangs "Anfrage-Token"
# (Header Auth) in der Oberflaeche ein, und das Formular schickt ihn im Kopf
# "x-anfrage-token" mit. Hier steht er nur, damit er einmal erzeugt ist.
ANFRAGE_TOKEN=${WEBHOOK_TOKEN}

# Zeitzone und Aufräumen der Läufe
GENERIC_TIMEZONE=${ZEITZONE}
TZ=${ZEITZONE}
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=$(( LAUFE_TAGE * 24 ))
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000

# Nichts nach draußen erzählen, nichts aus der Umgebung preisgeben
N8N_DIAGNOSTICS_ENABLED=false
N8N_BLOCK_ENV_ACCESS_IN_NODE=true
N8N_PAYLOAD_SIZE_MAX=16
UMG
    chmod 600 "$UMGEBUNG"
    ok "Umgebungsdatei angelegt (${UMGEBUNG}, nur für root lesbar)"
    warnung "Den Wert von N8N_ENCRYPTION_KEY JETZT in den Passwortspeicher übertragen."
  else
    info "… würde ${UMGEBUNG} anlegen, mit neuem Verschlüsselungsschlüssel und Datenbankkennwort"
  fi
fi

# =====================================================================
# 3 · docker-compose.yml
# =====================================================================
schritt "3 · Beschreibung der Container"
COMPOSE="${VERZEICHNIS}/docker-compose.yml"
if (( AUSFUEHREN )); then
  cat > "$COMPOSE" <<'COMP'
services:

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [intern]

  n8n:
    image: docker.n8n.io/n8nio/n8n:1.114.0
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_HOST: ${N8N_HOST}
      N8N_PORT: 5678
      N8N_PROTOCOL: ${N8N_PROTOCOL}
      WEBHOOK_URL: ${WEBHOOK_URL}
      N8N_EDITOR_BASE_URL: ${N8N_EDITOR_BASE_URL}
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: db
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${TZ}
      EXECUTIONS_DATA_PRUNE: ${EXECUTIONS_DATA_PRUNE}
      EXECUTIONS_DATA_MAX_AGE: ${EXECUTIONS_DATA_MAX_AGE}
      EXECUTIONS_DATA_PRUNE_MAX_COUNT: ${EXECUTIONS_DATA_PRUNE_MAX_COUNT}
      N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED}
      N8N_BLOCK_ENV_ACCESS_IN_NODE: ${N8N_BLOCK_ENV_ACCESS_IN_NODE}
      N8N_PAYLOAD_SIZE_MAX: ${N8N_PAYLOAD_SIZE_MAX}
    volumes:
      - ./daten:/home/node/.n8n
      - ./ablaeufe:/ablaeufe
    labels:
      # Traefik veröffentlicht n8n unter der Domain und holt das Zertifikat.
      # Nach außen ist nur 443 offen — 5678 wird nirgends freigegeben.
      traefik.enable: "true"
      traefik.docker.network: coolify
      traefik.http.routers.n8n.rule: Host(`${DOMAIN}`)
      traefik.http.routers.n8n.entrypoints: https
      traefik.http.routers.n8n.tls: "true"
      traefik.http.routers.n8n.tls.certresolver: letsencrypt
      traefik.http.services.n8n.loadbalancer.server.port: "5678"
    networks: [intern, coolify]

networks:
  intern:
    internal: true
  coolify:
    external: true
COMP
  ok "docker-compose.yml geschrieben (${COMPOSE})"
  info "Die Datenbank hängt nur im internen Netz — sie ist von außen nicht erreichbar."
else
  info "… würde ${COMPOSE} schreiben (n8n + PostgreSQL, Traefik-Labels, internes Netz)"
fi

# =====================================================================
# 4 · Starten
# =====================================================================
schritt "4 · Starten"
tue "cd '${VERZEICHNIS}' && docker compose pull"
tue "cd '${VERZEICHNIS}' && docker compose up -d"
if (( AUSFUEHREN )); then
  for _ in $(seq 1 30); do
    if docker compose -f "$COMPOSE" ps --format '{{.Service}} {{.State}}' 2>/dev/null | grep -q 'n8n running'; then break; fi
    sleep 2
  done
  docker compose -f "$COMPOSE" ps || true
  ok "Container gestartet"
fi

# =====================================================================
# 5 · Notfallumschlag
# =====================================================================
schritt "5 · Notfallumschlag"
if (( AUSFUEHREN )); then
  if [[ -x "$(dirname "$0")/n8n-sichern.sh" ]] || [[ -f "$(dirname "$0")/n8n-sichern.sh" ]]; then
    bash "$(dirname "$0")/n8n-sichern.sh" --verzeichnis "$VERZEICHNIS" --ziel "$UMSCHLAG" || warnung "Sicherung fehlgeschlagen — von Hand nachholen"
  else
    warnung "n8n-sichern.sh liegt nicht daneben — Sicherung von Hand einrichten"
  fi
else
  info "… würde n8n-sichern.sh laufen lassen und ${UMSCHLAG} anlegen"
fi

# =====================================================================
# Ergebnis
# =====================================================================
printf '\n%s✓ Fertig%s\n' "$F_OK" "$F_AUS"
cat <<ENDTEXT

  Nächste Schritte
    1. https://${DOMAIN} aufrufen und das erste Konto anlegen — wer zuerst kommt,
       wird Eigentümer. Das sollten Sie sein, nicht der Zufall.
    2. Zweiten Faktor einschalten (Einstellungen → eigenes Konto).
    3. N8N_ENCRYPTION_KEY aus ${UMGEBUNG} in den Passwortspeicher übertragen.
    4. Den Umschlag vom Server holen:
       scp betreiber@${DOMAIN}:${UMSCHLAG} ~/Desktop/
    5. Prüfen:  sudo bash n8n-check.sh --domain ${DOMAIN}

ENDTEXT
Station 01/opt/n8n/.env
N8N_ENCRYPTION_KEY=<openssl rand -hex 32>
DOMAIN=ablauf.example.de
N8N_HOST=ablauf.example.de
N8N_PROTOCOL=https
WEBHOOK_URL=https://ablauf.example.de/
N8N_EDITOR_BASE_URL=https://ablauf.example.de/
POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=<langes Kennwort>
GENERIC_TIMEZONE=Europe/Berlin
TZ=Europe/Berlin
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
N8N_DIAGNOSTICS_ENABLED=false
N8N_BLOCK_ENV_ACCESS_IN_NODE=true
N8N_PAYLOAD_SIZE_MAX=16
Station 01nachsehen, was wirklich offen ist
sudo ufw status
docker compose -f /opt/n8n/docker-compose.yml ps
Station 01in der Umgebungsdatei
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336        # Stunden — hier 14 Tage
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
Station 01unterlagen/checkliste-absicherung.md
# Checkliste: n8n sicher betreiben

> Reihenfolge ist Absicht: Was oben steht, schließt die Tür, die am
> wahrscheinlichsten offen steht. Geprüft wird mit `n8n-check.sh`, wo möglich.

## Zugang zur Oberfläche

- [ ] Ein Konto je Person — kein geteiltes Anmelden
- [ ] Zweiter Faktor eingeschaltet, mindestens für den Eigentümer
- [ ] Ausgeschiedene Personen sind gesperrt (nicht nur „nicht mehr benutzt")
- [ ] Neue Konten legt nur an, wer schon drin ist — keine offene Registrierung

## Netz

- [ ] Nur 443 nach außen offen; Port 5678 ist nirgends veröffentlicht
- [ ] Die Datenbank hängt im internen Netz und ist von außen nicht erreichbar
- [ ] HTTPS mit gültigem Zertifikat, keine Ausnahme für „schnell mal"

## Der Auslöser von außen (Webhook)

- [ ] Kennwort oder Signatur wird geprüft — eine geheime Adresse ist **keine** Absicherung
- [ ] Es werden nur die erwarteten Felder übernommen, alles andere verworfen
- [ ] Die Größe der Anfrage ist begrenzt
- [ ] Die Antwort verrät nichts über das Innere

## Geheimnisse

- [ ] `N8N_ENCRYPTION_KEY` ist gesetzt und liegt **zusätzlich** im Passwortspeicher
- [ ] Die Umgebungsdatei ist nur für root lesbar (`chmod 600`)
- [ ] Kein Geheimnis steht in einem Knoten, einer Notiz oder einem Ausdruck
- [ ] Jeder Zugang hat nur die Rechte, die er braucht (eigener Datenbankbenutzer, eigenes Postfach)

## Sicherung

- [ ] Tägliche Sicherung von Datenbank **und** Schlüssel (`n8n-sichern.sh`)
- [ ] Die Sicherung liegt außerhalb des Servers
- [ ] Einmal im Quartal auf einen leeren Container zurückgespielt — mit Probelauf
- [ ] Die Abläufe liegen als JSON im Repository, mit Historie

## Daten

- [ ] Die Ausführungen werden aufgeräumt (`EXECUTIONS_DATA_PRUNE`), mit einer begründeten Frist
- [ ] Es ist bekannt, welche personenbezogenen Daten in den Ausführungen stehen
- [ ] Fremdsysteme, die Daten sehen, sind im Verarbeitungsverzeichnis erfasst

## Pflege

- [ ] Die n8n-Fassung ist festgenagelt (kein `latest`)
- [ ] Vor jeder Aktualisierung: Sicherung, danach: ein Probelauf
- [ ] Die Änderungsliste der neuen Fassung wurde gelesen
Station 01täglich, am besten als Zeitplan in Coolify
sudo bash automatisierung/n8n-sichern.sh
# zusätzlich verschlüsselt:
sudo bash automatisierung/n8n-sichern.sh --passwort
Station 01n8n-sichern.sh
#!/usr/bin/env bash
#
# n8n-sichern.sh — Datenbank, Schlüssel und Abläufe in eine Datei.
#
# n8n verschlüsselt alle hinterlegten Zugänge mit einem einzigen Schlüssel:
# N8N_ENCRYPTION_KEY. Wer die Datenbank sichert, diesen Schlüssel aber nicht,
# hat eine Sicherung, die sich nicht mehr öffnen lässt — die Daten sind
# vorhanden und unlesbar. Deshalb legt dieses Skript beides zusammen.
#
#   sudo bash n8n-sichern.sh
#   sudo bash n8n-sichern.sh --passwort              (zusätzlich verschlüsselt)
#   sudo bash n8n-sichern.sh --ziel /root/umschlag.tar.gz --leise
#
# Es ändert nichts an der laufenden Installation und liest nur.
# Der Umschlag gehört VOM SERVER HERUNTER — sonst ist er im Ernstfall
# genau dort, wo er nicht mehr erreichbar ist.

set -Eeuo pipefail

VERZEICHNIS="/opt/n8n"
ZIEL=""
VERSCHLUESSELN=0
LEISE=0
BEHALTEN=14        # so viele Sicherungen bleiben im Verzeichnis liegen

if [[ -t 1 ]]; then
  F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_LEISE=$'\e[2m'; F_AUS=$'\e[0m'
else
  F_OK=""; F_WARN=""; F_LEISE=""; F_AUS=""
fi
ok()      { (( LEISE )) || printf '  %s✓%s %s\n' "$F_OK" "$F_AUS" "$1"; }
info()    { (( LEISE )) || printf '  %s·%s %s\n' "$F_LEISE" "$F_AUS" "$1"; }
warnung() { printf '  %s!%s %s\n' "$F_WARN" "$F_AUS" "$1" >&2; }
abbruch() { printf '\n✗ %s\n  Nächster Schritt: %s\n\n' "$1" "$2" >&2; exit 1; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --verzeichnis) VERZEICHNIS="${2:?}"; shift 2 ;;
    --ziel)        ZIEL="${2:?}"; shift 2 ;;
    --passwort)    VERSCHLUESSELN=1; shift ;;
    --leise)       LEISE=1; shift ;;
    -h|--hilfe|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) abbruch "Unbekannte Angabe: $1" "bash $0 --hilfe" ;;
  esac
done

COMPOSE="${VERZEICHNIS}/docker-compose.yml"
UMGEBUNG="${VERZEICHNIS}/.env"
[[ -f "$COMPOSE" ]]  || abbruch "${COMPOSE} fehlt." "Verzeichnis angeben: --verzeichnis /pfad/zu/n8n"
[[ -r "$UMGEBUNG" ]] || abbruch "${UMGEBUNG} nicht lesbar." "Mit sudo ausführen."

STAND="$(date '+%Y-%m-%d_%H%M')"
ORDNER="${VERZEICHNIS}/sicherung"
[[ -n "$ZIEL" ]] || ZIEL="${ORDNER}/n8n-${STAND}.tar.gz"
mkdir -p "$ORDNER"

ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
PAKET="${ARBEIT}/n8n-${STAND}"
mkdir -p "$PAKET"

dc() { docker compose -f "$COMPOSE" "$@"; }
umg() { grep -E "^${1}=" "$UMGEBUNG" | head -1 | cut -d= -f2-; }

# 1 · Der Schlüssel und die Umgebung
cp "$UMGEBUNG" "${PAKET}/umgebung.env"
chmod 600 "${PAKET}/umgebung.env"
ok "Umgebung samt Verschlüsselungsschlüssel übernommen"

# 2 · Die Datenbank
DBID="$(dc ps -q db 2>/dev/null | head -1)"
if [[ -n "$DBID" ]]; then
  if docker exec "$DBID" pg_dump -U "$(umg POSTGRES_USER)" -d "$(umg POSTGRES_DB)" \
       > "${PAKET}/datenbank.sql" 2>/dev/null; then
    ok "Datenbank gesichert ($(du -h "${PAKET}/datenbank.sql" | cut -f1))"
  else
    warnung "pg_dump fehlgeschlagen — Sicherung ist unvollständig"
  fi
else
  warnung "Datenbank-Container läuft nicht — keine Datenbanksicherung"
fi

# 3 · Die Abläufe als lesbare Dateien (ohne Zugangsdaten)
N8NID="$(dc ps -q n8n 2>/dev/null | head -1)"
if [[ -n "$N8NID" ]]; then
  mkdir -p "${PAKET}/ablaeufe"
  if docker exec "$N8NID" n8n export:workflow --all --pretty --separate --output=/ablaeufe/ >/dev/null 2>&1; then
    cp -r "${VERZEICHNIS}/ablaeufe/." "${PAKET}/ablaeufe/" 2>/dev/null || true
    ok "Abläufe als JSON gesichert ($(find "${PAKET}/ablaeufe" -name '*.json' | wc -l | tr -d ' ') Dateien)"
    info "Zugangsdaten sind darin NICHT enthalten — sie stehen verschlüsselt in der Datenbank."
  else
    warnung "Export der Abläufe fehlgeschlagen — die Datenbanksicherung enthält sie trotzdem"
  fi
fi

# 4 · Anleitung zum Zurückspielen
cat > "${PAKET}/ANLEITUNG.txt" <<TXT
Wiederherstellung — n8n, Stand ${STAND}

Voraussetzung: ein Server mit Docker, wie in Kurs 1 aufgesetzt.

1. Verzeichnis anlegen und diese Dateien hineinlegen:
     mkdir -p /opt/n8n && cd /opt/n8n

2. Umgebung zurückspielen — MIT dem alten Verschlüsselungsschlüssel:
     cp umgebung.env /opt/n8n/.env && chmod 600 /opt/n8n/.env
   Ein anderer Schlüssel macht alle hinterlegten Zugänge unlesbar.
   Das ist der häufigste Fehler beim Zurückspielen.

3. docker-compose.yml aus dem Repository holen (oder n8n-aufsetzen.sh laufen
   lassen — es lässt eine bestehende .env unangetastet).

4. Nur die Datenbank starten und den Abzug einspielen:
     docker compose up -d db
     docker compose exec -T db psql -U n8n -d n8n < datenbank.sql

5. n8n starten und anmelden:
     docker compose up -d

6. Die Probe: einen Ablauf von Hand auslösen und im Protokoll nachsehen,
   ob er durchläuft. Erst dann war die Sicherung eine.

Was NICHT in diesem Paket ist: die Daten der Fremdsysteme (Postfach,
Datenbank der Anwendung, Dateiablagen). Die haben ihre eigene Sicherung.
TXT

# 5 · Einpacken
tar -czf "${ARBEIT}/paket.tar.gz" -C "$ARBEIT" "n8n-${STAND}"
if (( VERSCHLUESSELN )); then
  command -v openssl >/dev/null || abbruch "openssl fehlt." "Ohne --passwort erneut ausführen."
  openssl enc -aes-256-cbc -pbkdf2 -salt -in "${ARBEIT}/paket.tar.gz" -out "${ZIEL}.enc"
  ZIEL="${ZIEL}.enc"
else
  mv "${ARBEIT}/paket.tar.gz" "$ZIEL"
fi
chmod 600 "$ZIEL"
ok "Sicherung liegt hier: ${ZIEL} ($(du -h "$ZIEL" | cut -f1))"

# 6 · Alte Sicherungen aufräumen
ANZAHL="$(ls -1t "${ORDNER}"/n8n-*.tar.gz* 2>/dev/null | wc -l | tr -d ' ')"
if (( ANZAHL > BEHALTEN )); then
  ls -1t "${ORDNER}"/n8n-*.tar.gz* | tail -n +$((BEHALTEN+1)) | xargs -r rm -f
  info "Alte Sicherungen entfernt — die letzten ${BEHALTEN} bleiben liegen"
fi

(( LEISE )) || cat <<TXT

  Der wichtigste Schritt fehlt noch: die Datei vom Server holen.
    scp betreiber@$(umg DOMAIN 2>/dev/null || hostname):${ZIEL} ~/Desktop/

  Und einmal im Quartal: auf einen leeren Container zurückspielen.
  Eine Sicherung, die nicht zurückgespielt wurde, ist keine.

TXT
Station 01auf dem Ersatzserver
docker compose up -d db
docker compose exec -T db psql -U n8n -d n8n < datenbank.sql
docker compose up -d
Station 01auf dem Server
sudo bash automatisierung/n8n-check.sh --domain ablauf.example.de
Station 01so sieht eine gesunde Anlage aus
Serverprüfung — n8n — ablauf.example.de — 03.09.2026 09:14

Betrieb
  ✓ Installation                       /opt/n8n
  ✓ Container n8n                      läuft
  ✓ Container db                       läuft
  ✓ Dauerneustarts                     0
  ✓ n8n-Fassung                        1.114.0
  ✓ Port nach außen                    keiner — nur über Traefik
  ✓ Datenbank nach außen               nicht erreichbar

Schlüssel und Sicherung
  ✓ Umgebungsdatei                     nur für root lesbar
  ✓ Verschlüsselungsschlüssel          gesetzt (64 Zeichen)
  ✓ Notfallumschlag                    2 Tage alt
  ✓ Fingerabdruck                      Umschlag passt zum Schlüssel
  ✓ Sicherung                          0 Tage alt

Läufe
  ✓ Aufräumen der Läufe                an (336 Stunden)
  ✓ Letzter Lauf                       vor 1 Stunden
  ✓ Abgebrochene Läufe (24 h)          0

Erreichbarkeit
  ✓ Oberfläche                         antwortet (200)
  ✓ Zertifikat                         gültig, noch 74 Tage

Alles in Ordnung — 17 Punkte geprüft
Station 01n8n-check.sh
#!/usr/bin/env bash
#
# n8n-check.sh — "Laufen meine Abläufe, und sind sie sicher?"
#
# Ein Befehl, eine Antwort. Läuft in ein paar Sekunden, ändert nichts.
# Jede Zeile ist entweder in Ordnung, ein Hinweis oder ein Problem —
# und jedes Problem nennt den nächsten Schritt.
#
#   sudo bash n8n-check.sh
#   sudo bash n8n-check.sh --domain ablauf.example.de   (prüft zusätzlich das Zertifikat)
#
# Rückgabewert: 0 = alles in Ordnung, 1 = mindestens ein Problem.
# Damit taugt der Befehl auch für die Überwachung — und die Ausgabe lässt
# sich in der Akademie in die Phase einfügen, die die Nachweise abhakt.

set -uo pipefail

# Umlaute sollen die Spaltenbreite nicht verschieben: eine UTF-8-Umgebung wählen,
# damit die Shell Zeichen zählt und nicht Bytes.
if [[ "${LC_ALL:-${LANG:-}}" != *UTF-8* && "${LC_ALL:-${LANG:-}}" != *utf8* ]]; then
  VERFUEGBAR="$(locale -a 2>/dev/null || true)"
  for L in C.UTF-8 de_DE.UTF-8 en_US.UTF-8; do
    case $'\n'"$VERFUEGBAR"$'\n' in *$'\n'"$L"$'\n'*) export LC_ALL="$L"; break ;; esac
  done
fi

DOMAIN=""
VERZEICHNIS="/opt/n8n"
UMSCHLAG="/root/n8n-notfallumschlag.tar.gz"
STILLSTAND_STUNDEN=24        # so lange darf der letzte Lauf her sein

while [[ $# -gt 0 ]]; do
  case "$1" in
    --domain)      DOMAIN="${2:-}"; shift 2 ;;
    --verzeichnis) VERZEICHNIS="${2:-}"; shift 2 ;;
    --umschlag)    UMSCHLAG="${2:-}"; shift 2 ;;
    -h|--hilfe|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) shift ;;
  esac
done

if [[ -t 1 ]]; then
  F_TITEL=$'\e[1m'; F_OK=$'\e[32m'; F_WARN=$'\e[33m'; F_FEHLER=$'\e[31m'; F_AUS=$'\e[0m'
else
  F_TITEL=""; F_OK=""; F_WARN=""; F_FEHLER=""; F_AUS=""
fi

GEPRUEFT=0; HINWEISE=0; PROBLEME=0
declare -a SCHRITTE=()
titel()   { printf '\n%s%s%s\n' "$F_TITEL" "$1" "$F_AUS"; }
# Auf 34 ZEICHEN auffüllen (nicht auf Bytes) — Umlaute sollen die Spalte nicht verschieben.
breit()   { local x="$1" n; n=$(( 34 - ${#x} )); (( n < 0 )) && n=0; printf '%s%*s' "$x" "$n" ''; }
gut()     { GEPRUEFT=$((GEPRUEFT+1)); printf '  %s✓%s %s %s\n' "$F_OK" "$F_AUS" "$(breit "$1")" "${2:-}"; }
hinweis() { GEPRUEFT=$((GEPRUEFT+1)); HINWEISE=$((HINWEISE+1)); printf '  %s!%s %s %s\n' "$F_WARN" "$F_AUS" "$(breit "$1")" "${2:-}"; [[ -n "${3:-}" ]] && SCHRITTE+=("$3"); }
schlecht(){ GEPRUEFT=$((GEPRUEFT+1)); PROBLEME=$((PROBLEME+1)); printf '  %s✗%s %s %s\n' "$F_FEHLER" "$F_AUS" "$(breit "$1")" "${2:-}"; [[ -n "${3:-}" ]] && SCHRITTE+=("$3"); }

printf '%s\n' "${F_TITEL}Serverprüfung — n8n — $(hostname) — $(date '+%d.%m.%Y %H:%M')${F_AUS}"

COMPOSE="${VERZEICHNIS}/docker-compose.yml"
UMGEBUNG="${VERZEICHNIS}/.env"
dc() { docker compose -f "$COMPOSE" "$@" 2>/dev/null; }
umg() { [[ -r "$UMGEBUNG" ]] && grep -E "^${1}=" "$UMGEBUNG" | head -1 | cut -d= -f2- || true; }

# ---------------------------------------------------------------- Betrieb
titel "Betrieb"

if ! command -v docker >/dev/null; then
  schlecht "Docker" "nicht vorhanden" "Erst bootstrap.sh aus Kurs 1 laufen lassen"
elif [[ ! -f "$COMPOSE" ]]; then
  schlecht "Installation" "${COMPOSE} fehlt" "n8n-aufsetzen.sh laufen lassen — oder --verzeichnis angeben"
else
  gut "Installation" "$VERZEICHNIS"
  ZUSTAND="$(dc ps --format '{{.Service}} {{.State}}')"
  for DIENST in n8n db; do
    ZEILE="$(grep -E "^${DIENST} " <<<"$ZUSTAND" | head -1)"
    NAME="Container ${DIENST}"
    if [[ -z "$ZEILE" ]]; then
      schlecht "$NAME" "läuft nicht" "cd ${VERZEICHNIS} && docker compose up -d"
    elif grep -q 'running' <<<"$ZEILE"; then
      gut "$NAME" "läuft"
    else
      schlecht "$NAME" "${ZEILE#* }" "cd ${VERZEICHNIS} && docker compose logs --tail=50 ${DIENST}"
    fi
  done

  CID="$(dc ps -q n8n | head -1)"
  if [[ -n "$CID" ]]; then
    NEUSTARTS="$(docker inspect -f '{{.RestartCount}}' "$CID" 2>/dev/null || echo 0)"
    if (( NEUSTARTS > 5 )); then
      schlecht "Dauerneustarts" "${NEUSTARTS} Neustarts" "docker logs --tail=80 ${CID} — meist fehlt eine Umgebungsvariable"
    else
      gut "Dauerneustarts" "${NEUSTARTS}"
    fi
    FASSUNG="$(docker inspect -f '{{.Config.Image}}' "$CID" 2>/dev/null)"
    if [[ "$FASSUNG" == *:latest ]]; then
      hinweis "n8n-Fassung" "latest" "Feste Fassung eintragen — sonst ändert sich beim nächsten Neustart unangekündigt alles"
    else
      gut "n8n-Fassung" "${FASSUNG##*:}"
    fi
    OFFEN="$(docker inspect -f '{{json .NetworkSettings.Ports}}' "$CID" 2>/dev/null)"
    if grep -q 'HostPort' <<<"$OFFEN"; then
      schlecht "Port nach außen" "5678 ist veröffentlicht" "In docker-compose.yml den ports-Abschnitt entfernen — Traefik erreicht n8n über das Docker-Netz"
    else
      gut "Port nach außen" "keiner — nur über Traefik"
    fi
  fi

  DBID="$(dc ps -q db | head -1)"
  if [[ -n "$DBID" ]]; then
    if docker inspect -f '{{json .NetworkSettings.Ports}}' "$DBID" 2>/dev/null | grep -q 'HostPort'; then
      schlecht "Datenbank nach außen" "veröffentlicht" "ports beim Dienst db entfernen — die Datenbank gehört ins interne Netz"
    else
      gut "Datenbank nach außen" "nicht erreichbar"
    fi
  fi
fi

# ------------------------------------------------------ Schlüssel und Sicherung
titel "Schlüssel und Sicherung"

if [[ ! -r "$UMGEBUNG" ]]; then
  schlecht "Umgebungsdatei" "nicht lesbar" "Mit sudo ausführen — oder n8n-aufsetzen.sh laufen lassen"
else
  RECHTE="$(stat -c '%a' "$UMGEBUNG" 2>/dev/null || stat -f '%OLp' "$UMGEBUNG" 2>/dev/null)"
  if [[ "$RECHTE" == "600" ]]; then gut "Umgebungsdatei" "nur für root lesbar"
  else schlecht "Umgebungsdatei" "Rechte ${RECHTE}" "chmod 600 ${UMGEBUNG}"; fi

  SCHLUESSEL="$(umg N8N_ENCRYPTION_KEY)"
  if [[ -z "$SCHLUESSEL" ]]; then
    schlecht "Verschlüsselungsschlüssel" "nicht gesetzt" "N8N_ENCRYPTION_KEY setzen — sonst würfelt n8n bei jedem Start einen neuen"
  elif (( ${#SCHLUESSEL} < 32 )); then
    hinweis "Verschlüsselungsschlüssel" "kurz (${#SCHLUESSEL} Zeichen)" "Länger wählen: openssl rand -hex 32 — Achtung, ein Wechsel macht bestehende Zugänge unlesbar"
  else
    gut "Verschlüsselungsschlüssel" "gesetzt (${#SCHLUESSEL} Zeichen)"
  fi

  if [[ -f "$UMSCHLAG" ]]; then
    ALTER=$(( ( $(date +%s) - $(stat -c %Y "$UMSCHLAG" 2>/dev/null || stat -f %m "$UMSCHLAG") ) / 86400 ))
    if (( ALTER > 30 )); then
      hinweis "Notfallumschlag" "${ALTER} Tage alt" "sudo bash n8n-sichern.sh — und die neue Datei vom Server holen"
    else
      gut "Notfallumschlag" "${ALTER} Tage alt"
    fi
    if [[ -n "$SCHLUESSEL" ]] && tar -xzOf "$UMSCHLAG" 2>/dev/null | grep -q "$SCHLUESSEL"; then
      gut "Fingerabdruck" "Umschlag passt zum Schlüssel"
    else
      hinweis "Fingerabdruck" "nicht überprüfbar" "Umschlag neu erzeugen: sudo bash n8n-sichern.sh"
    fi
  else
    schlecht "Notfallumschlag" "fehlt" "sudo bash n8n-sichern.sh — ohne den Schlüssel ist jede Sicherung wertlos"
  fi

  LETZTE="$(ls -1t "${VERZEICHNIS}"/sicherung/*.tar.gz 2>/dev/null | head -1)"
  if [[ -n "$LETZTE" ]]; then
    TAGE=$(( ( $(date +%s) - $(stat -c %Y "$LETZTE" 2>/dev/null || stat -f %m "$LETZTE") ) / 86400 ))
    if (( TAGE > 2 )); then hinweis "Sicherung" "${TAGE} Tage alt" "Tägliche Sicherung einrichten (Coolify Scheduled Task oder cron)"
    else gut "Sicherung" "${TAGE} Tage alt"; fi
  else
    hinweis "Sicherung" "keine gefunden" "sudo bash n8n-sichern.sh täglich laufen lassen — und den Rückweg einmal üben"
  fi
fi

# ---------------------------------------------------------------- Läufe
titel "Läufe"

PRUNE="$(umg EXECUTIONS_DATA_PRUNE)"
FRIST="$(umg EXECUTIONS_DATA_MAX_AGE)"
if [[ "$PRUNE" == "true" ]]; then
  gut "Aufräumen der Läufe" "an${FRIST:+ (${FRIST} Stunden)}"
else
  hinweis "Aufräumen der Läufe" "aus" "EXECUTIONS_DATA_PRUNE=true setzen — sonst wachsen die Daten jedes Laufs unbegrenzt"
fi

if [[ -n "${DBID:-}" ]]; then
  DB_USER="$(umg POSTGRES_USER)"; DB_NAME="$(umg POSTGRES_DB)"
  psql_() { docker exec -i "$DBID" psql -U "${DB_USER:-n8n}" -d "${DB_NAME:-n8n}" -tA -c "$1" 2>/dev/null; }
  LETZTER="$(psql_ "select extract(epoch from now() - max(\"startedAt\"))::int from execution_entity;")"
  if [[ -n "$LETZTER" && "$LETZTER" =~ ^[0-9]+$ ]]; then
    STD=$(( LETZTER / 3600 ))
    if (( STD > STILLSTAND_STUNDEN )); then
      schlecht "Letzter Lauf" "vor ${STD} Stunden" "Das ist der leise Stillstand. Auslöser prüfen — Station 05"
    else
      gut "Letzter Lauf" "vor ${STD} Stunden"
    fi
  else
    hinweis "Letzter Lauf" "nicht ablesbar" "Noch kein Lauf, oder die Datenbank antwortet nicht"
  fi
  FEHLER="$(psql_ "select count(*) from execution_entity where status='error' and \"startedAt\" > now() - interval '24 hours';")"
  if [[ -n "$FEHLER" && "$FEHLER" =~ ^[0-9]+$ ]]; then
    if (( FEHLER > 0 )); then hinweis "Abgebrochene Läufe (24 h)" "${FEHLER}" "In n8n unter Ausführungen ansehen — und prüfen, ob der Fehler-Ablauf gemeldet hat"
    else gut "Abgebrochene Läufe (24 h)" "0"; fi
  fi
fi

# ---------------------------------------------------------- Erreichbarkeit
if [[ -n "$DOMAIN" ]]; then
  titel "Erreichbarkeit"
  CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${DOMAIN}/" 2>/dev/null)"
  case "$CODE" in
    200|401|302) gut "Oberfläche" "antwortet (${CODE})" ;;
    000) schlecht "Oberfläche" "keine Antwort" "DNS, Traefik-Label und Container prüfen" ;;
    *)   hinweis "Oberfläche" "antwortet mit ${CODE}" "In Traefik nachsehen, ob der Router greift" ;;
  esac
  ENDE_ROH="$(echo | timeout 8 openssl s_client -servername "$DOMAIN" -connect "${DOMAIN}:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"
  if [[ -n "$ENDE_ROH" ]]; then
    TAGE=$(( ( $(date -d "$ENDE_ROH" +%s 2>/dev/null || echo 0) - $(date +%s) ) / 86400 ))
    if   (( TAGE < 0  )); then schlecht "Zertifikat" "abgelaufen" "Domain in Coolify neu speichern — Traefik holt ein neues"
    elif (( TAGE < 14 )); then hinweis  "Zertifikat" "läuft in ${TAGE} Tagen ab" "Sollte sich selbst erneuern. Wenn nicht: Domain neu speichern"
    else                       gut      "Zertifikat" "gültig, noch ${TAGE} Tage"; fi
  else
    hinweis "Zertifikat" "keines abrufbar" "Kommt erst, wenn Traefik die Domain kennt"
  fi
fi

# ---------------------------------------------------------------- Ergebnis
printf '\n%s' "$F_TITEL"
if   (( PROBLEME > 0 )); then printf '%s%d Problem(e), %d Hinweis(e) — %d Punkte geprüft%s\n' "$F_FEHLER" "$PROBLEME" "$HINWEISE" "$GEPRUEFT" "$F_AUS"
elif (( HINWEISE > 0 )); then printf '%sKeine Probleme, %d Hinweis(e) — %d Punkte geprüft%s\n' "$F_WARN" "$HINWEISE" "$GEPRUEFT" "$F_AUS"
else                          printf '%sAlles in Ordnung — %d Punkte geprüft%s\n' "$F_OK" "$GEPRUEFT" "$F_AUS"; fi

if (( ${#SCHRITTE[@]} > 0 )); then
  printf '\n%sNächste Schritte%s\n' "$F_TITEL" "$F_AUS"
  N=1; for S in "${SCHRITTE[@]}"; do printf '  %d. %s\n' "$N" "$S"; N=$((N+1)); done
fi
printf '\n'

(( PROBLEME == 0 ))
Zum Aushängen

Alle Diagramme

Dieselben Bilder wie im Kurs, hintereinander und druckbar. Sie stehen auf einer eigenen Seite — 11 Zeichnungen auf einmal machen jede andere Seite träge.

11 Diagramme ansehen und druckenVon der Landkarte der sieben Orte bis zur Sicherungskette.
Material ist Werkzeug, nicht der Stoff. Skripte, Vorlagen und Ihre eigene Prüfliste nehmen Sie mit. Die Phasentexte bleiben im Kurs — sie werden gepflegt, und eine Kopie wäre schon morgen falsch.