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:
curl -X POST 'https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/auth/device' \
-d "client_id=scp" \
-d "scope=offline_access openid" | jqDu erhältst eine Antwort wie:
{
"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:
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' | jqErgebnis:
{
"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):
echo "<refresh-token>" > /root/.netcup_refresh_token
chmod 600 /root/.netcup_refresh_tokenDieses 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:
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.:
123456 v1234561234561234561Diesen 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 Musterddmmyyyy#benannt und mit dem PräfixAutoBackup_…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.
#!/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
sudo crontab -eDann einfügen:
30 3 * * * /home/netcup_snapshot.shDamit 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:
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
- Netcup SCP API Dokumentation – Offizielle API-Referenz.
- OAuth 2.0 Device Flow (RFC 8628) – Technische Spezifikation des Authentifizierungsprozesses.
- jq – JSON Command-Line Tool – Zum Auslesen der JSON-Antworten im Skript.
- GNU Bash Dokumentation Beitrag wurde am 12.10.2025 erstellt.

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
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.
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)=