Netcup SCP API:
Tägliche automatische (Server-Backups) & Snapshots mit Bash einrichten
(vollständige Anleitung)

In diesem Beitrag zeige ich, wie man mit der SCP-API von Netcup automatisierte tägliche Snapshots seiner Root- oder VPS-Server einrichtet.
Das Ganze läuft vollständig über ein Bash-Skript, das auf einem Ubuntu-Server (z. B. via Cronjob) regelmäßig Snapshots erstellt, alte löscht und API-Fehler robust behandelt.

**⚡ Update (März 2026) – Version 2.0:** Das Skript wurde komplett überarbeitet. Neu in v2: Lockfile-Schutz, sichere Temp-Dateien (mktemp), automatische Lokal/Remote-Erkennung, intelligentes Token-Caching, optimiertes API-Polling, Dryrun-Absicherung und verbesserte Sicherheit (kein `curl -k`, keine Tokens in Logs).

1. Authentifizierung über die SCP-API (Device Flow)

Schritt 1 – Device-Code anfordern
Auf dem Server (oder lokal in WSL) eingeben:

Bash
curl -X POST 'https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/auth/device' \
  -d "client_id=scp" \
  -d "scope=offline_access openid" | jq

Du erhältst eine Antwort wie:

JavaScript
{
  "device_code": "ABC123...",
  "user_code": "XYZ789",
  "verification_uri_complete": "https://www.servercontrolpanel.de/realms/scp/device?user_code=XYZ789",
  "expires_in": 600
}

Schritt 2 – Verifizieren im Browser

Öffne die angegebene URL im Browser und logge dich in dein Netcup-Konto ein:
👉 https://www.servercontrolpanel.de/realms/scp/device?user_code=XYZ789
Nach der Anmeldung bestätigst du die Freigabe („Grant Access“) für offline_access, profile, email, etc.

Schritt 3 – Access- & Refresh-Token abrufen

Zurück auf deinem Server:

Bash
curl -X POST 'https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
  -d 'device_code=<device-code>' \
  -d 'client_id=scp' | jq

Ergebnis:

JavaScript
{
  "access_token": "<access-token>",
  "refresh_token": "<refresh-token>",
  "expires_in": 300
}

Schritt 4 – Refresh-Token sichern
Speichere das Refresh-Token sicher (z. B. root-only):

Bash
echo "<refresh-token>" > /root/.netcup_refresh_token
chmod 600 /root/.netcup_refresh_token

Dieses Token brauchst du, damit dein Skript sich automatisch ein neues Access-Token holen kann, ohne dass du dich erneut anmelden musst.

(Optional) Server-ID und Servernamen abrufen
Falls du deine Server-ID nicht direkt kennst, kannst du sie mit diesem Befehl automatisch auslesen:

Bash
curl -s -X POST "https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token" \
  -d "client_id=scp" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$(cat /root/.netcup_refresh_token)" \
  | jq -r '.access_token' | \
  xargs -I{} curl -s -H "Authorization: Bearer {}" \
  "https://www.servercontrolpanel.de/scp-core/api/v1/servers" | \
  jq -r '.[] | "\(.id) \t \(.name)"'

Ergebnis: Die erste Zahl ist deine Server-ID,
z. B.:

Bash
123456    v1234561234561234561

Diesen Server-ID brauchst du für den Script unten.

2. Einführung: Was das Skript macht

Das folgende Bash-Skript erstellt automatisch tägliche Snapshots (Backups) deines Netcup-Servers über die SCP-Core API.
Es sorgt dafür, dass du immer eine aktuelle Sicherung hast – ganz ohne manuelles Eingreifen.

Dabei führt es folgende Aufgaben aus:

  • 🧠 Tägliche Automatisierung:
    Automatisch wird jeden Tag ein neuer Snapshot erstellt, z. B.:
    -AutoBackup_xyz 16.10.2025-1
    -AutoBackup_xyz 17.10.2025-1
    -AutoBackup_xyz 18.10.2025-1
    -AutoBackup_xyz 19.10.2025-1

    oder

    Alternativ können – je nach Crontab-Einstellung – auch mehrere Snapshots pro Tag erzeugt werden, z. B.:
    – AutoBackup_xyz 16.10.2025-1
    – AutoBackup_xyz 16.10.2025-2
    – AutoBackup_xyz 16.10.2025-3
    – AutoBackup_xyz 17.10.2025-1

  • 🔁 FIFO-Rotation (konfigurierbar):
    Das Skript behält eine einstellbare Anzahl an Snapshots (MAX_SNAPSHOTS) und löscht
    in einem Durchlauf automatisch alle überzähligen – nicht nur einen wie in v1.
  • 🧩 Eindeutige Namen nach Datum:
    Jeder Snapshot wird automatisch nach Datum und Zähler benannt, z. B. 121020251, 121020252, usw.
  • 🧾 Lokaler Abgleich mit dem Server:
    Das Skript führt eine lokale Index-Datei und gleicht diese mit den Snapshots im SCP ab,
    damit die Verwaltung auch bei API-Fehlern konsistent bleibt.
  • 🔐 Sichere Token-Verwaltung:
    Es holt sich automatisch ein frisches Access-Token über dein gespeichertes Refresh-Token,
    sodass du dich nie erneut anmelden musst.
  • ⚙️ Robustes Fehler-Handling:
    API-Fehler (z. B. HTTP 400/500 oder Sperren) werden automatisch erkannt und erneut versucht.
  • 🧼 Manuell erstellte Snapshots bleiben unberührt:
    Das Skript löscht nur automatisch erzeugte Snapshots,
    die nach dem Muster ddmmyyyy# benannt und mit dem Präfix AutoBackup_… versehen sind.
    Alle anderen – also manuell erstellte Backups – bleiben vollständig erhalten.
  • 🛡️ Lockfile-Schutz:
    Verhindert, dass das Skript doppelt läuft – z. B. bei überlappenden Cronjobs.
  • 📂 Sichere Temp-Dateien (mktemp):
    Statt offener Dateien in /tmp werden isolierte Temp-Ordner erstellt und beim Beenden automatisch gelöscht.
  • ⏱️ Intelligentes Token-Caching:
    Der Token wird nur erneuert, wenn er älter als 4 Minuten ist – keine unnötigen API-Requests bei mehreren Löschungen.
  • 🔍 Voraussetzungs-Check:
    Das Skript prüft beim Start, ob curl, jq, mktemp und hostname installiert sind und ob die Refresh-Token-Datei existiert.
  • 🤖 Automatische Lokal/Remote-Erkennung:
    Erkennt anhand von IP und Hostname, ob es auf dem Zielserver selbst oder remote läuft und passt sein Verhalten entsprechend an.
  • ⏱️ Optimiertes API-Polling:
    Getrennte Intervalle für Erstellen (60s × 30 Versuche ≈ 30 Min) und Löschen (20s × 15 Versuche ≈ 5 Min).
  • 🛡️ Verbesserte Sicherheit:
    Volle SSL-Prüfung (kein curl -k), sensible Tokens werden niemals in Logs geschrieben, und der Dryrun bricht bei echten API-Fehlern (400/409/500) ab, bevor der Server unnötig heruntergefahren wird.
  • ✅ Verifikation nach Erstellung:
    Im Remote-Modus prüft das Skript nach dem Erstellen automatisch, ob der Snapshot tatsächlich auf dem Server existiert.

Im Skript müssen nur die Zeilen 38 bis 63 angepasst werden
(der Konfigurationsblock zwischen „KONFIGURATION“ und „ENDE KONFIGURATION“)

💬 Hinweis:
Wenn ihr eigene Verbesserungen oder Erweiterungen am Skript umsetzen wollt, könnt ihr das natürlich gerne tun.
Bei mir funktioniert es in dieser Form jedoch genau so, wie ich es brauche – zuverlässig und stabil.

ich habe diesen Skript unter /home/netcup_snapshot.sh

Da bei mir mehrere Server unter demselben Netcup-Account laufen,
wird das Skript von einem anderen Server im Account ausgeführt (Remote-Modus).
Dadurch erhalte ich zuverlässig die Erfolgsmeldung
„✅ Snapshot erfolgreich angelegt: …“ inklusive automatischer Verifikation,
ob der Snapshot tatsächlich auf dem Server existiert.

Wenn ihr nur einen einzelnen Server habt, könnt ihr das Skript auch direkt lokal ausführen.
Das Skript erkennt automatisch, ob es auf dem Zielserver selbst läuft (Lokal-Modus).

In diesem Fall:
– Der Snapshot-Auftrag wird an die API gesendet
– Alle Daten werden sofort ins lokale Inventar (INDEX_FILE) geschrieben
– Im Log erscheint der Hinweis: „Server fährt für Offline-Snapshot herunter – kein Polling möglich“
– Nach dem Neustart ist der Snapshot vorhanden und wird beim nächsten Lauf automatisch erkannt

Das funktioniert zuverlässig – der Snapshot wird auch ohne Polling korrekt erstellt
und ist anschließend im SCP-Core sichtbar.

Am besten ist es ohnehin, zwei Server zu verwenden,
die sich gegenseitig mit Zeitversatz sichern – das ist die stabilste Lösung.

Bash
#!/usr/bin/env bash
# ============================================================================
# Netcup SCP-CORE Snapshot Automation
# Version: 2.0.1
#
# Automatisiert das Erstellen und Rotieren von Offline-Snapshots für Netcup
# Root-Server über die SCP-CORE REST API.
#
# Features:
#   - Erkennt automatisch ob es auf dem Zielserver oder remote läuft
#   - FIFO-Rotation: hält eine konfigurierbare Anzahl an Backups
#   - Lokales Inventar wird mit dem Server synchronisiert
#   - Lockfile verhindert parallele Ausführung
#   - Token-Refresh bei langen Operationen
#   - Sichere Temp-Dateien, keine Secrets in Logs
#
# Voraussetzungen:
#   - bash >= 4.x, curl, jq
#   - Gültiger Netcup Refresh-Token (siehe REFRESH_TOKEN_FILE)
#
# Installation:
#   1. Variablen unten anpassen (SERVER_ID, DISK_NAME, Pfade etc.)
#   2. Refresh-Token in REFRESH_TOKEN_FILE ablegen:
#      echo "dein-refresh-token" > /root/.netcup_refresh_token
#      chmod 600 /root/.netcup_refresh_token
#   3. Cronjob anlegen, z.B.:
#      0 3 * * * /root/netcup_snapshot.sh >> /dev/null 2>&1
#
# Autor: Sam
# Webseite: https://www.webdesignelite.de
# Lizenz: CC BY-NC 4.0 - Frei nutzbar, aber kein Verkauf
# ============================================================================

# --- Strikte Fehlerbehandlung (ohne set -e, dafür explizit) ---
set -uo pipefail

# ========================= KONFIGURATION ====================================
# Passe diese Werte an deine Umgebung an!

API_BASE="https://www.servercontrolpanel.de/scp-core/api/v1"
TOKEN_URL="https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token"
CLIENT_ID="scp"

SERVER_ID="DEINE_SERVER_ID"                           # Zielserver-ID (SCP → Server → Details)
DISK_NAME="vda"                                       # Disk-Name (meist "vda")
LOGFILE="/home/netcup_snapshot.log"                    # Log-Datei
REFRESH_TOKEN_FILE="/root/.netcup_refresh_token"       # Netcup Refresh-Token
ACCESS_TOKEN_FILE="/root/.netcup_access_token"         # Wird automatisch verwaltet
INDEX_FILE="/root/.netcup_snapshot_list"               # Lokales Snapshot-Inventar
MAX_SNAPSHOTS=4                                        # Wie viele Snapshots behalten
BACKUP_PREFIX="AutoBackup_"                            # Prefix in Snapshot-Beschreibung
LOCKFILE="/tmp/netcup_snapshot.lock"                   # Lockfile gegen parallele Runs

# Polling-Konfiguration
POLL_RETRY_INTERVAL=5                                  # Sekunden bei HTTP-Fehler

# Create-Polling (Offline-Snapshot: Server herunterfahren + Disk-Snapshot + Boot)
POLL_CREATE_MAX_TRIES=30                               # Max. Versuche (30 × 60s = ~30 Min)
POLL_CREATE_INTERVAL=60                                # Sekunden zwischen Polls

# Delete-Polling (Löschung dauert meist nur 10-60 Sek)
POLL_DELETE_MAX_TRIES=15                               # Max. Versuche (15 × 20s = ~5 Min)
POLL_DELETE_INTERVAL=20                                # Sekunden zwischen Polls

# ========================= ENDE KONFIGURATION ================================

# --- Globale Variablen ---
RUNNING_ON_TARGET=false
ACCESS_TOKEN=""
TOKEN_OBTAINED_AT=0                                    # Epoch-Timestamp des letzten Token-Abrufs
TOKEN_MAX_AGE=240                                      # Token erneuern nach 4 Min (240 Sek)
EXISTING=""
AUTOS_TSV=""
TMPDIR_SCRIPT=""

# --- Sichere temporäre Dateien ---
setup_tmpdir() {
  TMPDIR_SCRIPT=$(mktemp -d "${TMPDIR:-/tmp}/netcup_snap.XXXXXXXXXX")
  chmod 700 "$TMPDIR_SCRIPT"
}

cleanup() {
  rm -rf "$TMPDIR_SCRIPT" 2>/dev/null || true
  rm -f "$LOCKFILE" 2>/dev/null || true
}

# --- Logging ---
ts() { date +"[%Y-%m-%d %H:%M:%S]"; }
log() { echo "$(ts) $1" | tee -a "$LOGFILE"; }

# --- Voraussetzungen prüfen ---
check_prerequisites() {
  local missing=()
  for cmd in curl jq mktemp hostname; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
      missing+=("$cmd")
    fi
  done
  if (( ${#missing[@]} > 0 )); then
    echo "[FEHLER] Fehlende Abhängigkeiten: ${missing[*]}" >&2
    exit 1
  fi

  if [[ ! -f "$REFRESH_TOKEN_FILE" ]]; then
    echo "[FEHLER] Refresh-Token nicht gefunden: $REFRESH_TOKEN_FILE" >&2
    echo "  Erstelle die Datei mit: echo 'DEIN_TOKEN' > $REFRESH_TOKEN_FILE && chmod 600 $REFRESH_TOKEN_FILE" >&2
    exit 1
  fi

  # Sicherstellen, dass Token-Dateien die richtigen Rechte haben
  chmod 600 "$REFRESH_TOKEN_FILE" 2>/dev/null || true
}

# --- Dateien initialisieren ---
init_files() {
  touch "$LOGFILE" && chmod 600 "$LOGFILE"
  touch "$INDEX_FILE" && chmod 600 "$INDEX_FILE"
}

# --- Lockfile: verhindert parallele Ausführung ---
acquire_lock() {
  if [[ -f "$LOCKFILE" ]]; then
    local lock_pid
    lock_pid=$(cat "$LOCKFILE" 2>/dev/null || true)
    if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
      log "[ABBRUCH] Anderer Snapshot-Job läuft bereits (PID $lock_pid)."
      exit 0
    else
      log "[INFO] Verwaistes Lockfile gefunden (PID $lock_pid), wird entfernt."
      rm -f "$LOCKFILE"
    fi
  fi
  echo $$ > "$LOCKFILE"
}

# --- Access-Token holen (mit Refresh-Token) ---
# Maskiert sensible Daten in Fehlermeldungen
get_access_token() {
  local rt at nt resp http_code

  rt=$(cat "$REFRESH_TOKEN_FILE" 2>/dev/null || true)
  if [[ -z "$rt" ]]; then
    log "[FEHLER] Refresh-Token-Datei ist leer: $REFRESH_TOKEN_FILE"
    exit 1
  fi

  resp=$(curl -sS -w "\n%{http_code}" -X POST "$TOKEN_URL" \
    -d "client_id=$CLIENT_ID" \
    -d "grant_type=refresh_token" \
    -d "refresh_token=$rt" 2>&1) || true

  # HTTP-Code aus letzter Zeile extrahieren
  http_code=$(echo "$resp" | tail -1)
  resp=$(echo "$resp" | sed '$d')

  at=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null || true)
  nt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null || true)

  if [[ -z "$at" ]]; then
    # Fehlermeldung OHNE Tokens loggen
    local safe_error
    safe_error=$(echo "$resp" | jq -r '.error_description // .error // "Unbekannter Fehler"' 2>/dev/null || echo "Token-Abruf fehlgeschlagen (HTTP $http_code)")
    log "[FEHLER] Konnte kein Access-Token abrufen: $safe_error"
    exit 1
  fi

  echo "$at" > "$ACCESS_TOKEN_FILE"
  chmod 600 "$ACCESS_TOKEN_FILE"

  if [[ -n "$nt" ]]; then
    echo "$nt" > "$REFRESH_TOKEN_FILE"
    chmod 600 "$REFRESH_TOKEN_FILE"
  fi

  ACCESS_TOKEN="$at"
  TOKEN_OBTAINED_AT=$(date +%s)
}

# --- Token bei Bedarf erneuern ---
# Erneuert den Token nur, wenn er älter als TOKEN_MAX_AGE Sekunden ist.
# Verhindert unnötige Token-Requests bei vielen FIFO-Löschungen.
refresh_token_if_needed() {
  local now
  now=$(date +%s)
  local age=$(( now - TOKEN_OBTAINED_AT ))
  if (( age >= TOKEN_MAX_AGE )); then
    log "[INFO] Token ist ${age}s alt - erneuere..."
    get_access_token
  fi
}

# --- Erkennen, ob wir auf dem Zielserver selbst laufen ---
detect_same_server() {
  RUNNING_ON_TARGET=false

  # Lokale IPs sammeln (IPv4)
  local local_ips
  local_ips=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' || \
              ip -4 addr show 2>/dev/null | grep -oP '(?<=inet\s)[0-9]+(\.[0-9]+){3}' || true)

  if [[ -z "$local_ips" ]]; then
    log "[INFO] Konnte lokale IPs nicht ermitteln - nehme an: Remote-Server."
    return 0
  fi

  # Zielserver-Details von API abrufen
  local resp
  resp=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
    "$API_BASE/servers/$SERVER_ID" 2>/dev/null || true)

  if [[ -z "$resp" ]]; then
    log "[INFO] Konnte Serverdaten nicht abrufen - nehme an: Remote-Server."
    return 0
  fi

  # IPs des Zielservers extrahieren (verschiedene API-Formate)
  local target_ips
  target_ips=$(echo "$resp" | jq -r '
    (.ipAddresses // .ips // .networks // []) |
    if type == "array" then
      .[] | (if type == "object" then (.ip // .address // .ipAddress // empty) else . end)
    else empty end' 2>/dev/null || true)

  # Vergleich: IPs
  local tip lip
  for tip in $target_ips; do
    for lip in $local_ips; do
      if [[ "$tip" == "$lip" ]]; then
        RUNNING_ON_TARGET=true
        return 0
      fi
    done
  done

  # Fallback: Hostname vergleichen
  local target_hostname local_hostname
  target_hostname=$(echo "$resp" | jq -r '.hostname // .name // empty' 2>/dev/null || true)
  local_hostname=$(hostname 2>/dev/null || true)

  if [[ -n "$target_hostname" && -n "$local_hostname" ]]; then
    if [[ "$local_hostname" == "$target_hostname" || \
          "$local_hostname" == "${target_hostname%%.*}" ]]; then
      RUNNING_ON_TARGET=true
      return 0
    fi
  fi
}

# --- Task-Polling (für 202 Accepted) ---
# Aufruf: poll_task "uuid" [max_tries] [interval]
#   max_tries  - optional, Default: POLL_CREATE_MAX_TRIES
#   interval   - optional, Default: POLL_CREATE_INTERVAL
poll_task() {
  local uuid="$1"
  local max_tries="${2:-$POLL_CREATE_MAX_TRIES}"
  local interval="${3:-$POLL_CREATE_INTERVAL}"
  local tries=0
  local task_file="$TMPDIR_SCRIPT/task.json"

  while (( tries < max_tries )); do
    local code
    code=$(curl -s -w "%{http_code}" -o "$task_file" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      "$API_BASE/tasks/$uuid" 2>/dev/null) || true

    if [[ "$code" != "200" ]]; then
      log "[INFO] Task $uuid: HTTP $code - retry in ${POLL_RETRY_INTERVAL}s..."
      sleep "$POLL_RETRY_INTERVAL"
      ((tries++)) || true
      continue
    fi

    local state
    state=$(jq -r '.state // .status // empty' "$task_file" 2>/dev/null || true)

    case "$state" in
      FINISHED|SUCCESS)
        log "✅ Task $uuid: $state"
        return 0
        ;;
      FAILED|ERROR)
        local detail
        detail=$(jq -r '.message // .error // empty' "$task_file" 2>/dev/null || true)
        log "❌ Task $uuid: $state${detail:+ - $detail}"
        return 1
        ;;
      *)
        log "[INFO] Task $uuid: ${state:-UNBEKANNT} - warte ${interval}s..."
        sleep "$interval"
        ((tries++)) || true
        ;;
    esac
  done

  log "[WARNUNG] Task $uuid: Timeout nach $max_tries Versuchen."
  return 1
}

# --- Robuste JSON-Extraktion: API kann Array oder {data:[]} liefern ---
# Gibt pro Snapshot eine JSON-Zeile aus
extract_snapshots_jq() {
  jq -c 'if type == "array" then .[]? else (.data // [])[]? end' "$1" 2>/dev/null || true
}

# --- Snapshot-Liste abrufen ---
fetch_snapshots() {
  local tries=0
  local snap_file="$TMPDIR_SCRIPT/snapshots.json"
  local code

  while (( tries < 5 )); do
    local accept
    for accept in "application/hal+json" "application/json" "*/*"; do
      code=$(curl -s -w "%{http_code}" -o "$snap_file" \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        -H "Accept: $accept" \
        "$API_BASE/servers/$SERVER_ID/snapshots?limit=50" 2>/dev/null) || true

      if [[ "$code" == "200" ]]; then
        if jq -e . >/dev/null 2>&1 < "$snap_file"; then
          return 0
        fi
      fi
    done
    log "[INFO] Snapshot-Liste: Versuch $((tries+1))/5 fehlgeschlagen (HTTP ${code:-?}) - warte 5s..."
    sleep 5
    ((tries++)) || true
  done

  log "[FEHLER] Snapshot-Liste konnte nach 5 Versuchen nicht geladen werden."
  return 1
}

# --- Snapshot löschen ---
delete_snapshot_by_name() {
  local name="$1"
  local delete_file="$TMPDIR_SCRIPT/delete_result.json"

  # Sicherheitscheck: nur AutoBackup-Namen (YYYYMMDD oder altes DDMMYYYY + Ziffer(n)) löschen
  if ! [[ "$name" =~ ^([0-9]{4}[01][0-9][0-3][0-9][0-9]+|[0-3][0-9][01][0-9][0-9]{4}[0-9]+)$ ]]; then
    log "[SICHERHEIT] Überspringe Löschung von '$name' (kein gültiger AutoBackup-Name)."
    return 1
  fi

  local enc
  enc=$(printf '%s' "$name" | jq -sRr @uri)
  log "🗑️ Lösche Auto-Snapshot: $name"

  local code attempt=1
  while (( attempt <= 3 )); do
    # Token erneuern falls nötig (Löschung kann lange dauern)
    if (( attempt > 1 )); then
      refresh_token_if_needed
    fi

    code=$(curl -s -w "%{http_code}" -o "$delete_file" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -X DELETE "$API_BASE/servers/$SERVER_ID/snapshots/$enc" 2>/dev/null) || true

    if [[ "$code" == "202" ]]; then
      local uuid
      uuid=$(jq -r '.uuid // .taskId // .id // empty' "$delete_file" 2>/dev/null || true)
      if [[ -n "$uuid" ]]; then
        if ! poll_task "$uuid" "$POLL_DELETE_MAX_TRIES" "$POLL_DELETE_INTERVAL"; then
          log "[WARNUNG] Lösch-Task $uuid meldet Fehler."
        fi
      fi
      # Aus lokalem Index entfernen
      local tmp_idx
      tmp_idx=$(mktemp "$TMPDIR_SCRIPT/idx.XXXXXX")
      grep -v "^${name}," "$INDEX_FILE" > "$tmp_idx" 2>/dev/null || true
      mv "$tmp_idx" "$INDEX_FILE"
      chmod 600 "$INDEX_FILE"
      log "✅ $name gelöscht."
      sleep 20
      return 0
    fi

    # Server-Lock: warten und erneut versuchen
    if grep -q "server.lock.error" "$delete_file" 2>/dev/null; then
      log "[INFO] Server gesperrt - warte 15s (Versuch $attempt/3)..."
      sleep 15
      ((attempt++)) || true
      continue
    fi

    # Anderer Fehler
    local err_msg
    err_msg=$(jq -r '.message // .error // empty' "$delete_file" 2>/dev/null || true)
    log "[WARNUNG] Delete $name → HTTP $code${err_msg:+ - $err_msg}"
    return 1
  done

  log "[FEHLER] $name: Löschen nach 3 Versuchen nicht möglich."
  return 1
}

# --- Lokales Inventar mit Server abgleichen ---
sync_local_index() {
  if ! fetch_snapshots; then
    log "[WARNUNG] Kann Snapshot-Liste nicht laden - überspringe Sync."
    return 0
  fi

  local snap_file="$TMPDIR_SCRIPT/snapshots.json"

  # Prüfe ob leeres Array zurückkam
  local is_empty
  is_empty=$(jq '. == [] or . == null' "$snap_file" 2>/dev/null || echo "false")
  if [[ "$is_empty" == "true" ]]; then
    log "[INFO] Noch kein Snapshot vorhanden."
    return 0
  fi

  local names_on_server
  names_on_server=$(jq -r --arg prefix "$BACKUP_PREFIX" '
    (if type == "array" then .[] else (.data // [])[] end) |
    select(.description // "" | startswith($prefix)) | .name' \
    "$snap_file" 2>/dev/null | sort -u || true)

  log "[INFO] Synchronisiere lokales Inventar (Filter: $BACKUP_PREFIX)..."

  local tmp_idx
  tmp_idx=$(mktemp "$TMPDIR_SCRIPT/sync.XXXXXX")
  while IFS=, read -r name date; do
    [[ -z "$name" ]] && continue
    if echo "$names_on_server" | grep -qx -- "$name"; then
      echo "$name,$date" >> "$tmp_idx"
    else
      log "[INFO] Entferne verwaisten Eintrag: $name"
    fi
  done < "$INDEX_FILE"
  mv "$tmp_idx" "$INDEX_FILE"
  chmod 600 "$INDEX_FILE"
}

# --- EXISTING & AUTOS_TSV aus Snapshot-JSON neu aufbauen ---
rebuild_autos_vars() {
  local snap_file="$TMPDIR_SCRIPT/snapshots.json"

  # Prüfe ob leeres Array
  local is_empty
  is_empty=$(jq '. == [] or . == null' "$snap_file" 2>/dev/null || echo "false")
  if [[ "$is_empty" == "true" ]]; then
    EXISTING=""
    AUTOS_TSV=""
    return 0
  fi

  EXISTING=$(extract_snapshots_jq "$snap_file")
  AUTOS_TSV=""
  if [[ -n "${EXISTING:-}" ]]; then
    AUTOS_TSV=$(echo "$EXISTING" | jq -r --arg prefix "$BACKUP_PREFIX" '
      select(.description // "" | startswith($prefix)) |
      [.name, .description,
       (.creationTime // .createDate // .createdAt // .created // .creationDate // "")]
      | @tsv' 2>/dev/null || true)
  fi
}

# --- Prüfen, ob ein Snapshot-Name bereits existiert ---
name_exists() {
  local nm="$1"

  # Auf dem Server prüfen
  if [[ -n "${EXISTING:-}" ]]; then
    if echo "$EXISTING" | jq -r '.name' 2>/dev/null | grep -qx -- "$nm"; then
      return 0
    fi
  fi

  # Im lokalen Index prüfen
  if grep -q -- "^${nm}," "$INDEX_FILE" 2>/dev/null; then
    return 0
  fi

  return 1
}

# --- Nächsten freien Snapshot-Namen generieren ---
generate_snapshot_name() {
  local basename
  basename=$(date +"%Y%m%d")

  # Heute vorhandene Namen aus Serverliste
  local today_server_names
  today_server_names=$(echo "$AUTOS_TSV" | \
    awk -v base="$basename" -F'\t' '$1 ~ ("^"base"[0-9]+$"){print $1}' || true)

  # Heute vorhandene Namen aus lokalem Index
  local today_index_names
  today_index_names=$(awk -F',' -v base="$basename" \
    '$1 ~ ("^"base"[0-9]+$"){print $1}' "$INDEX_FILE" 2>/dev/null || true)

  # Höchste bislang verwendete Nummer für heute ermitteln
  local last_num=0 suff
  local nm
  for nm in $today_server_names $today_index_names; do
    suff="${nm#$basename}"
    if [[ "$suff" =~ ^[0-9]+$ ]] && (( suff > last_num )); then
      last_num=$suff
    fi
  done

  local counter=$((last_num + 1))
  SNAPSHOT_NAME="${basename}${counter}"

  # Sicherheit: solange erhöhen, bis der Name wirklich frei ist
  while name_exists "$SNAPSHOT_NAME"; do
    ((counter++)) || true
    SNAPSHOT_NAME="${basename}${counter}"
  done

  SNAPSHOT_DESC="${BACKUP_PREFIX} $(date +'%d.%m.%Y')-${counter}"
}

# ============================================================================
# HAUPTPROGRAMM
# ============================================================================

main() {
  # --- Voraussetzungen ---
  check_prerequisites
  init_files
  setup_tmpdir
  acquire_lock
  trap cleanup EXIT INT TERM

  log "----------------------------------------"
  log "Starte Snapshot-Job für Server-ID $SERVER_ID"

  # --- Token holen ---
  get_access_token

  # --- Lokal oder Remote? ---
  detect_same_server
  if [[ "$RUNNING_ON_TARGET" == "true" ]]; then
    log "[INFO] Modus: LOKAL (Script läuft auf dem Zielserver)"
  else
    log "[INFO] Modus: REMOTE (Script läuft auf anderem Server)"
  fi

  # --- Inventar synchronisieren ---
  sync_local_index

  # --- Snapshot-Liste laden ---
  if fetch_snapshots; then
    rebuild_autos_vars
  else
    log "[WARNUNG] Snapshot-Liste nicht verfügbar - nutze lokales Inventar."
    EXISTING=""
    AUTOS_TSV=""
  fi

  # --- Lokales Inventar ergänzen (Server → Index) ---
  if [[ -n "$AUTOS_TSV" ]]; then
    while IFS=$'\t' read -r n _ d; do
      [[ -z "$n" ]] && continue
      if ! grep -q "^$n," "$INDEX_FILE" 2>/dev/null; then
        echo "$n,${d:-}" >> "$INDEX_FILE"
      fi
    done <<< "$AUTOS_TSV"
  fi

  # --- FIFO: überzählige Snapshots löschen ---
  local total_count
  total_count=$(echo "$AUTOS_TSV" | sed '/^$/d' | wc -l)

  while (( total_count >= MAX_SNAPSHOTS )); do
    # Token erneuern (FIFO-Loop kann lange dauern)
    refresh_token_if_needed

    # creationTime in Epoch umwandeln → zuverlässige chronologische Sortierung
    # Fallback: Datum aus Snapshot-Name ableiten (YYYYMMDD oder altes DDMMYYYY)
    local victim
    victim=$(echo "$AUTOS_TSV" | sed '/^$/d' | while IFS=$'\t' read -r _n _d _c; do
      _ep=""
      # 1. Versuch: creationTime von der API parsen
      if [[ -n "$_c" ]]; then
        _ep=$(date -d "$_c" +%s 2>/dev/null || echo "")
      fi
      # 2. Fallback: Datum aus Snapshot-Name extrahieren
      if [[ -z "$_ep" ]]; then
        if [[ "$_n" =~ ^(20[0-9]{2})([01][0-9])([0-3][0-9])[0-9]+$ ]]; then
          # YYYYMMDD-Format (neu)
          _ep=$(date -d "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}" +%s 2>/dev/null || echo "")
        elif [[ "$_n" =~ ^([0-3][0-9])([01][0-9])([0-9]{4})[0-9]+$ ]]; then
          # DDMMYYYY-Format (alt)
          _ep=$(date -d "${BASH_REMATCH[3]}-${BASH_REMATCH[2]}-${BASH_REMATCH[1]}" +%s 2>/dev/null || echo "")
        fi
      fi
      [[ -z "$_ep" ]] && _ep=9999999999
      printf '%s\t%s\n' "$_ep" "$_n"
    done | sort -t$'\t' -k1,1n | head -n1 | cut -d$'\t' -f2)

    if [[ -z "$victim" ]]; then
      log "[WARNUNG] Konnte keinen Snapshot zum Löschen ermitteln."
      break
    fi

    log "[INFO] Maximalanzahl ($MAX_SNAPSHOTS) erreicht - lösche ältesten Snapshot: $victim"

    if ! delete_snapshot_by_name "$victim"; then
      log "[WARNUNG] Konnte $victim nicht löschen - breche FIFO ab."
      break
    fi

    # Snapshot-Liste frisch laden
    if fetch_snapshots; then
      rebuild_autos_vars
    else
      # Fallback: Opfer lokal entfernen
      AUTOS_TSV=$(echo "$AUTOS_TSV" | awk -v v="$victim" -F'\t' '$1!=v{print $0}')
    fi

    total_count=$(echo "$AUTOS_TSV" | sed '/^$/d' | wc -l)
  done

  # --- Snapshot-Name generieren ---
  generate_snapshot_name
  log "Erstelle neuen automatischen Offline-Snapshot: $SNAPSHOT_NAME"

  # --- Dryrun: Prüfen ob Snapshot möglich ist ---
  local dry_file="$TMPDIR_SCRIPT/snap_dry.json"
  local dry_code
  dry_code=$(curl -s -w "%{http_code}" -o "$dry_file" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -X POST "$API_BASE/servers/$SERVER_ID/snapshots:dryrun" \
    -d "{\"diskName\":\"$DISK_NAME\",\"onlineSnapshot\":false}" 2>/dev/null) || true

  if [[ "$dry_code" == "200" ]]; then
    log "[INFO] Dryrun OK - Snapshot kann erstellt werden."
  elif [[ "$dry_code" == "404" ]]; then
    # Dryrun-Endpoint nicht verfügbar (ältere API-Version) - trotzdem fortfahren
    log "[INFO] Dryrun-Endpoint nicht verfügbar (404) - fahre trotzdem fort."
  else
    # Echter Fehler (400, 409, 500 etc.) - Snapshot wird nicht möglich sein
    local dry_err
    dry_err=$(jq -r '.message // .code // empty' "$dry_file" 2>/dev/null || true)
    log "[FEHLER] Dryrun fehlgeschlagen (HTTP $dry_code)${dry_err:+: $dry_err}"
    log "[FEHLER] Snapshot-Erstellung abgebrochen - Server wird NICHT heruntergefahren."
    exit 1
  fi

  # --- Token frisch holen (eventuell abgelaufen nach FIFO-Löschungen) ---
  refresh_token_if_needed

  # --- Snapshot erstellen ---
  local create_file="$TMPDIR_SCRIPT/create_result.json"
  local create_code
  create_code=$(curl -s -w "%{http_code}" -o "$create_file" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -X POST "$API_BASE/servers/$SERVER_ID/snapshots" \
    -d "{\"name\":\"$SNAPSHOT_NAME\",\"description\":\"$SNAPSHOT_DESC\",\"diskName\":\"$DISK_NAME\",\"onlineSnapshot\":false}" \
    2>/dev/null) || true

  if [[ "$create_code" != "202" ]]; then
    local err_msg
    err_msg=$(jq -r '.message // .code // empty' "$create_file" 2>/dev/null || true)
    log "[FEHLER] Snapshot konnte nicht erstellt werden (HTTP $create_code)${err_msg:+: $err_msg}"
    exit 1
  fi

  local task_uuid
  task_uuid=$(jq -r '.uuid // .taskId // .id // empty' "$create_file" 2>/dev/null || true)

  if [[ "$RUNNING_ON_TARGET" == "true" ]]; then
    # === LOKALER MODUS ===
    # Server fährt für Offline-Snapshot herunter → Script-Prozess wird gekillt.
    # Index JETZT schreiben, da wir gleich tot sind.
    echo "$SNAPSHOT_NAME,$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$INDEX_FILE"
    log "✅ Snapshot-Auftrag angenommen (202): $SNAPSHOT_NAME (Task: ${task_uuid:-?})"
    log "[INFO] Server fährt für Offline-Snapshot herunter - kein Polling möglich."
    log "----------------------------------------"
    # Script endet hier durch Server-Shutdown (oder normal, falls kein Offline-Snap)
  else
    # === REMOTE MODUS ===
    if [[ -n "$task_uuid" ]]; then
      log "[INFO] Create akzeptiert (202), Task $task_uuid - polling..."
      if poll_task "$task_uuid"; then
        echo "$SNAPSHOT_NAME,$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$INDEX_FILE"
        log "✅ Snapshot erfolgreich angelegt: $SNAPSHOT_NAME"

        # Verifikation
        sleep 5
        if fetch_snapshots; then
          local snap_file="$TMPDIR_SCRIPT/snapshots.json"
          if jq -e --arg name "$SNAPSHOT_NAME" \
            '(if type == "array" then .[] else (.data // [])[] end) |
             select(.name == $name)' "$snap_file" >/dev/null 2>&1; then
            log "[INFO] Verifikation OK: $SNAPSHOT_NAME existiert auf dem Server."
          else
            log "[WARNUNG] Verifikation: $SNAPSHOT_NAME nicht in Snapshot-Liste gefunden!"
          fi
        fi
      else
        log "❌ Snapshot-Create fehlgeschlagen (Task $task_uuid)."
        exit 1
      fi
    else
      # Kein Task-UUID → ungewöhnlich, aber möglich
      log "[WARNUNG] Kein Task-UUID in API-Antwort - warte 30s."
      sleep 30
      echo "$SNAPSHOT_NAME,$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$INDEX_FILE"
      log "[INFO] Snapshot $SNAPSHOT_NAME im Index eingetragen (nicht verifiziert)."
    fi
  fi

  log "----------------------------------------"
}

# --- Script starten ---
main "$@"

⚠️ Haftungsausschluss:
Ich übernehme keinerlei Verantwortung für Schäden, Datenverluste oder Fehlfunktionen, die durch die Nutzung oder Anpassung dieses Skripts entstehen könnten.
Bitte prüft die Konfiguration sorgfältig und setzt das Skript auf eigene Verantwortung ein.

Bei mir funktioniert es einwandfrei, aber das bedeutet nicht, dass es bei deiner Umgebung genauso läuft.

Wichtige Hinweise für den sicheren Einsatz:
1. Führt mindestens eine Woche Testphase durch, bevor ihr euch auf das automatische Backup verlässt.
2. Sichert eure Daten weiterhin manuell zusätzlich ab.
3. Prüft regelmäßig, ob die automatischen Backups korrekt erstellt werden.
4. Löscht hin und wieder einen automatischen Backup-Snapshot manuell im SCP-Panel, um sicherzustellen, dass das Skript die Differenz beim nächsten Lauf korrekt erkennt und das lokale Inventar automatisch bereinigt.
5. Verlasst euch nicht blind auf die Automatisierung – kontrolliert regelmäßig die Existenz und Vollständigkeit der Backups.

4. Cronjob für tägliche Ausführung

Bash
sudo crontab -e

Dann einfügen:

Bash
30 3 * * * /home/netcup_snapshot.sh

Damit läuft das Backup jeden Tag um 03:30 Uhr automatisch.

Bevor das Skript gestartet werden kann, solltest du sicherstellen,
dass es im richtigen Format (Unix-Format) vorliegt und ausführbar ist.

Dazu öffnest du nochmal dein Terminal und gibst ein:

Bash
dos2unix /home/netcup_snapshot.sh
chmod +x /home/netcup_snapshot.sh

Der erste Befehl entfernt Windows-Zeilenumbrüche (falls vorhanden),
und der zweite macht das Skript ausführbar.

💡 Wenn dir das Skript geholfen hat, lass gern einen Kommentar da oder teile den Beitrag.
Weitere Linux- und Netcup-Tutorials findest du auf webdesignelite.de

🔗 Nützliche externe Ressourcen

3 Kommentare

  1. korrigiere mich gern aber der folgende teil wird nicht ausgeführt weil der server kurz vorher ja wegen den offline snapshot sich herunterfährt, oder ?

    if [[ „$CREATE_CODE“ == „202“ ]]; then
    CUUID=$(jq -r ‚.uuid // .taskId // .id // empty‘ /tmp/create_result.json)
    if [[ -n „$CUUID“ ]]; then
    log „[INFO] Create akzeptiert (202), Task $CUUID – polling…“
    if poll_task „$ACCESS_TOKEN“ „$CUUID“; then
    log „✅ Snapshot erfolgreich angelegt: $NAME“
    else
    log „❌ Snapshot-Create fehlgeschlagen (Task $CUUID).“; exit 1
    fi
    else
    sleep 30
    fi
    else
    log „[FEHLER] Snapshot konnte nicht erstellt werden:“; cat /tmp/create_result.json | tee -a „$LOGFILE“; exit 1
    fi

    erste tests funktionieren ansonsten super. erstklassige arbeit

    1. Hi Rudger, freut mich, dass du auf meinen Beitrag gestoßen bist 😊
      Du hast absolut recht – ich habe in meinem Beitrag vergessen zu erwähnen,
      dass ich das Skript inzwischen auf einen zweiten Server im selben Account verschoben habe.

      Genau um solche Timing-Geschichten zu vermeiden und den letzten Log-Eintrag zuverlässig zu erhalten.
      Jetzt sichern sich die beiden Server gegenseitig – mit einer halben Stunde Zeitversatz.
      Das läuft deutlich stabiler.

      Danke aber für den Hinweis! Ich habe es jetzt im Beitrag ergänzt –
      das ist genau der Punkt, den man im Hinterkopf behalten sollte,
      wenn man das Skript lokal auf dem zu sichernden Server ausführt 👍

      Der Snapshot wird zwar erstellt, aber die Rückmeldung für den Log kommt dabei nicht mehr an.
      Da der Eintrag jedoch schon vorher im lokalen Verzeichnis hinterlegt wird, ist das kein Problem.
      Das Skript prüft ja alle Voraussetzungen im Vorfeld,
      und letztlich wird der Snapshot trotzdem korrekt erstellt – also alles gut.

      Optional könnte man noch ein kleines Prüfsystem einbauen,
      das etwa 60–90 Sekunden nach dem Neustart automatisch kontrolliert,
      ob der letzte Snapshot wirklich vorhanden ist.
      So hätte man den kompletten Status auch dann im Log,
      wenn der Server während des Offline-Snapshots heruntergefahren wurde.

  2. Hallo Sam, vielen Dank für dein Script, war auf der Suche nach einem Beispiel für die neue REST API.

    Es gibt aber leider noch einen kleinen Schönheitsfehler: wenn der entsprechende Server noch keinen Snapshot hat,
    beendet sich das Script mit diesem jq Fehler wegen leerem array:

    „`bash
    ++ code=200
    ++ [[ 200 == \2\0\0 ]]
    ++ jq -e .
    ++ echo 0
    ++ return 0
    + [[ 0 == \0 ]]
    + local names_on_server
    ++ jq -er –arg prefix AutoBackup_ ‚
    (.[]? // .data[]?) | select(.description | startswith($prefix)) | .name‘ /tmp/snapshots.json
    ++ sort -u
    jq: error (at /tmp/snapshots.json:0): Cannot index array with string „data“
    + names_on_server=
    „`
    Mit dieser Änderung funktioniert es:
    „`diff
    — netcup_snapshot.sh.org 2025-12-26 18:06:51.520342491 +0100
    +++ netcup_snapshot.sh 2025-12-26 19:25:55.527882479 +0100
    @@ -124,7 +124,12 @@
    local token=“$1″
    if [[ „$(fetch_snapshots „$token“)“ == „0“ ]]; then
    local names_on_server
    – names_on_server=$(jq -r –arg prefix „$BACKUP_PREFIX“ ‚
    + names_on_server=$(jq ‚. == []‘ /tmp/snapshots.json)
    + if [ „${names_on_server}“ == „true“ ] ; then
    + # we got an empty array back
    + log „[INFO] Noch kein Snapshot vorhanden.“
    + else
    + names_on_server=$(jq -er –arg prefix „$BACKUP_PREFIX“ ‚
    (.[]? // .data[]?) | select(.description | startswith($prefix)) | .name‘ /tmp/snapshots.json | sort -u)
    log „[INFO] Synchronisiere lokales Inventar (Filter: $BACKUP_PREFIX)…“
    local tmp=$(mktemp)
    @@ -137,6 +142,7 @@
    done < "$INDEX_FILE"
    mv "$tmp" "$INDEX_FILE"; chmod 600 "$INDEX_FILE"
    fi
    + fi
    }

    # — Helfer: EXISTING & AUTOS_TSV aus /tmp/snapshots.json neu aufbauen —
    „`
    Hoffe, ich konnte helfen.

    (gern Formatierung korrigieren, falls das so nicht passt)=

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert