Files
NetTransfare/nettmanage.py
T

1147 lines
41 KiB
Python

#!/usr/bin/env python3
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime
# ==========================================
# Standard-Konfiguration
# ==========================================
CONFIG = {
"netbox_path": "/opt/netbox",
"db_host": "localhost",
"db_name": "netbox",
"db_user": "netbox",
"db_pass": "",
"backup_dir": "/tmp",
# Leer lassen, damit upgrade.sh den Standard-Python-Interpreter verwendet.
# Beispiel für NetBox 4.5/4.6: /usr/bin/python3.12
"python_path": "",
}
NETBOX_SERVICES = ("netbox", "netbox-rq")
# Diese Einträge werden beim Upgrade auf Wunsch ergänzt. Bereits vorhandene
# Einträge (auch mit Versions-Pin) werden nicht doppelt hinzugefügt.
DEFAULT_PLUGIN_REQUIREMENTS = (
"netbox-topology-views",
"netbox-attachments",
"netbox-ping",
"netbox-reorder-rack",
"netbox-secrets",
"netbox-inventory",
"git+https://git.mrblake.cc/MrBlake/NetBox-VM-Import.git#egg=netbox_vmware_importer",
)
LDAP_REQUIREMENT = "django-auth-ldap"
# ==========================================
# Hilfsfunktionen für die Shell-GUI
# ==========================================
class Colors:
HEADER = "\033[95m"
BLUE = "\033[94m"
GREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
def clear_screen():
os.system("clear" if os.name == "posix" else "cls")
def print_header(title):
clear_screen()
print(Colors.HEADER + Colors.BOLD + "=" * 60 + Colors.ENDC)
print(Colors.HEADER + Colors.BOLD + f" {title}".ljust(59) + Colors.ENDC)
print(Colors.HEADER + Colors.BOLD + "=" * 60 + Colors.ENDC + "\n")
def log_info(msg):
print(Colors.BLUE + f"[*] {msg}" + Colors.ENDC)
def log_success(msg):
print(Colors.GREEN + Colors.BOLD + f"[+] {msg}" + Colors.ENDC)
def log_error(msg):
print(Colors.FAIL + Colors.BOLD + f"[-] {msg}" + Colors.ENDC)
def log_warn(msg):
print(Colors.WARNING + f"[!] {msg}" + Colors.ENDC)
def pause():
input(Colors.BOLD + "\nDrücke [ENTER], um ins Hauptmenü zurückzukehren..." + Colors.ENDC)
def run_command(command, *, env=None, cwd=None, capture=False):
"""Führt einen Befehl ohne Shell aus und liefert CompletedProcess zurück."""
kwargs = {
"env": env,
"cwd": cwd,
"text": True,
"check": False,
}
if capture:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
return subprocess.run(command, **kwargs)
def unique_path(path):
"""Erzeugt einen noch nicht belegten Pfad durch Anhängen einer Nummer."""
if not os.path.lexists(path):
return path
counter = 1
while True:
candidate = f"{path}_{counter}"
if not os.path.lexists(candidate):
return candidate
counter += 1
def safe_extract_zip(zipf, destination):
"""Verhindert Zip-Slip beim Entpacken von Backup-Dateien."""
destination_real = os.path.realpath(destination)
for member in zipf.infolist():
target = os.path.realpath(os.path.join(destination, member.filename))
if os.path.commonpath((destination_real, target)) != destination_real:
raise ValueError(f"Unsicherer ZIP-Pfad erkannt: {member.filename}")
zipf.extractall(destination)
def safe_extract_tar(tarf, destination):
"""Entpackt ein Release-Archiv ohne Pfad-Traversal oder Archiv-Symlinks."""
destination_real = os.path.realpath(destination)
for member in tarf.getmembers():
target = os.path.realpath(os.path.join(destination, member.name))
if os.path.commonpath((destination_real, target)) != destination_real:
raise ValueError(f"Unsicherer TAR-Pfad erkannt: {member.name}")
if member.issym() or member.islnk():
raise ValueError(f"Symlink/Hardlink im TAR-Archiv nicht erlaubt: {member.name}")
tarf.extractall(destination)
# ==========================================
# Kernfunktionen: Backup & Restore
# ==========================================
def execute_backup():
print_header("BACKUP PROZESS")
log_info("Konfiguriere den Backup-Umfang:")
do_db = input(" 1. Datenbank sichern? (J/n): ").strip().lower() != "n"
exclude_changelog = False
exclude_users = False
if do_db:
exclude_changelog = (
input(
Colors.BLUE
+ " -> Changelog (extras_objectchange) auslassen? Spart viel Platz. (j/N): "
+ Colors.ENDC
)
.strip()
.lower()
== "j"
)
print(
Colors.WARNING
+ " Hinweis: Das Auslassen von Benutzern kann beim Restore zu Foreign-Key-Fehlern führen, falls Objekte diesen Benutzern zugewiesen sind."
+ Colors.ENDC
)
exclude_users = (
input(
Colors.BLUE
+ " -> Benutzer (User, Gruppen, Tokens) auslassen? (j/N): "
+ Colors.ENDC
)
.strip()
.lower()
== "j"
)
do_media = input("\n 2. Media-Dateien (Bilder/Dokumente) sichern? (J/n): ").strip().lower() != "n"
if not do_db and not do_media:
log_warn("Keine Komponenten zum Sichern ausgewählt. Abbruch.")
pause()
return
media_path = os.path.join(CONFIG["netbox_path"], "netbox/media")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_name = f"netbox_backup_{stamp}"
zip_file_path = os.path.join(CONFIG["backup_dir"], f"{archive_name}.zip")
tmp_dir = os.path.join(CONFIG["backup_dir"], f"nb_tmp_{stamp}")
os.makedirs(tmp_dir, exist_ok=True)
try:
# 1. Datenbank-Dump
if do_db:
log_info("\nSchritt 1: Erstelle PostgreSQL-Datenbank-Dump...")
dump_file = os.path.join(tmp_dir, "netbox_database.dump")
env = os.environ.copy()
if CONFIG["db_pass"]:
env["PGPASSWORD"] = CONFIG["db_pass"]
cmd = [
"pg_dump",
"-Fc",
"-h",
CONFIG["db_host"],
"-U",
CONFIG["db_user"],
"-d",
CONFIG["db_name"],
"-f",
dump_file,
]
# --exclude-table-data lässt die Tabellenstruktur intakt,
# exportiert aber keine Inhalte der gewählten Tabellen.
if exclude_changelog:
cmd.append("--exclude-table-data=extras_objectchange")
log_info(" -> Changelog-Daten werden übersprungen.")
if exclude_users:
cmd.extend(
[
"--exclude-table-data=auth_user",
"--exclude-table-data=auth_group",
"--exclude-table-data=users_token",
]
)
log_info(" -> Benutzer-Daten werden übersprungen.")
process = run_command(cmd, env=env, capture=True)
if process.returncode != 0:
log_error(f"pg_dump fehlgeschlagen: {process.stderr}")
raise RuntimeError("Datenbank-Backup fehlgeschlagen.")
log_success("PostgreSQL-Dump erfolgreich erstellt.")
else:
log_info("\nSchritt 1: Datenbank-Backup übersprungen.")
# 2. Media-Dateien
if do_media:
log_info("Schritt 2: Kopiere Media-Dateien (Bilder, Dokumente)...")
if os.path.exists(media_path):
dst_media = os.path.join(tmp_dir, "media")
shutil.copytree(media_path, dst_media, dirs_exist_ok=True)
log_success("Media-Ordner erfolgreich gesichert.")
else:
log_warn(f"Kein Media-Verzeichnis unter {media_path} gefunden.")
else:
log_info("Schritt 2: Media-Backup übersprungen.")
# 3. Metadaten
log_info("Schritt 3: Erstelle Metadaten...")
meta = {
"version": "1.2",
"timestamp": stamp,
"source_db": CONFIG["db_name"],
"contains_db": do_db,
"contains_media": do_media,
"excluded_changelog": exclude_changelog,
"excluded_users": exclude_users,
}
with open(os.path.join(tmp_dir, "metadata.json"), "w", encoding="utf-8") as file_handle:
json.dump(meta, file_handle, indent=4)
# 4. ZIP erstellen
log_info(f"Schritt 4: Komprimiere Daten nach {zip_file_path} ...")
with zipfile.ZipFile(zip_file_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(tmp_dir):
for filename in files:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, tmp_dir)
zipf.write(full_path, rel_path)
print("\n" + "=" * 60)
log_success("BACKUP ERFOLGREICH ABGESCHLOSSEN!")
log_success(f"Datei liegt hier: {zip_file_path}")
print("=" * 60)
except Exception as exc:
log_error(f"Kritischer Fehler: {exc}")
finally:
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
pause()
def execute_restore():
print_header("RESTORE PROZESS")
log_warn("ACHTUNG: Dieser Vorgang überschreibt bestehende Daten auf der Ziel-Instanz!")
confirm = input("Bist du sicher, dass du fortfahren möchtest? (j/N): ")
if confirm.lower() != "j":
log_info("Abbruch durch Benutzer.")
pause()
return
restore_file = input("\nPfad zur Backup-ZIP-Datei (z. B. /tmp/netbox_backup.zip): ").strip()
if not restore_file or not os.path.exists(restore_file):
log_error(f"Die Datei '{restore_file}' existiert nicht!")
pause()
return
media_path = os.path.join(CONFIG["netbox_path"], "netbox/media")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
tmp_extract_dir = f"/tmp/nb_restore_{stamp}"
os.makedirs(tmp_extract_dir, exist_ok=True)
try:
log_info(f"\nSchritt 1: Entpacke {restore_file} ...")
with zipfile.ZipFile(restore_file, "r") as zipf:
safe_extract_zip(zipf, tmp_extract_dir)
meta_file = os.path.join(tmp_extract_dir, "metadata.json")
if not os.path.exists(meta_file):
raise RuntimeError("Ungültiges Backup: metadata.json fehlt.")
with open(meta_file, "r", encoding="utf-8") as file_handle:
meta = json.load(file_handle)
# 2. Datenbank-Restore
if meta.get("contains_db", True):
log_info("Schritt 2: Stelle PostgreSQL-Datenbank wieder her...")
dump_file = os.path.join(tmp_extract_dir, "netbox_database.dump")
if not os.path.exists(dump_file):
raise RuntimeError("Ungültiges Backup: netbox_database.dump fehlt.")
env = os.environ.copy()
if CONFIG["db_pass"]:
env["PGPASSWORD"] = CONFIG["db_pass"]
log_info("Lösche und erstelle Datenbank neu (Bereinigung)...")
drop_proc = run_command(
[
"dropdb",
"-h",
CONFIG["db_host"],
"-U",
CONFIG["db_user"],
"--if-exists",
CONFIG["db_name"],
],
env=env,
capture=True,
)
if drop_proc.returncode != 0:
raise RuntimeError(f"dropdb fehlgeschlagen: {drop_proc.stderr.strip()}")
create_proc = run_command(
[
"createdb",
"-h",
CONFIG["db_host"],
"-U",
CONFIG["db_user"],
"-O",
CONFIG["db_user"],
CONFIG["db_name"],
],
env=env,
capture=True,
)
if create_proc.returncode != 0:
raise RuntimeError(f"createdb fehlgeschlagen: {create_proc.stderr.strip()}")
log_info("Importiere Dump...")
proc_restore = run_command(
[
"pg_restore",
"-h",
CONFIG["db_host"],
"-U",
CONFIG["db_user"],
"-d",
CONFIG["db_name"],
"--clean",
"--no-owner",
dump_file,
],
env=env,
capture=True,
)
if proc_restore.returncode != 0:
log_warn(
f"pg_restore wurde mit Code {proc_restore.returncode} beendet:\n"
f"{proc_restore.stderr.strip()}"
)
log_success("Datenbank-Wiederherstellung abgeschlossen.")
else:
log_info("Schritt 2: Übersprungen (Keine Datenbank im Backup enthalten).")
# 3. Media-Restore
if meta.get("contains_media", True):
log_info("Schritt 3: Stelle Media-Dateien wieder her...")
src_media = os.path.join(tmp_extract_dir, "media")
if os.path.exists(src_media):
if os.path.exists(media_path):
shutil.rmtree(media_path)
shutil.copytree(src_media, media_path, dirs_exist_ok=True)
run_command(["chown", "-R", "netbox:netbox", media_path], capture=True)
log_success("Media-Dateien wiederhergestellt.")
else:
log_warn("Backup enthält laut Metadaten Media-Dateien, der Ordner fehlt jedoch.")
else:
log_info("Schritt 3: Übersprungen (Keine Media-Dateien im Backup enthalten).")
# 4. NetBox upgrade.sh
log_info("Schritt 4: Führe NetBox upgrade.sh aus (Cache-Clear & Migrationen)...")
upgrade_script = os.path.join(CONFIG["netbox_path"], "upgrade.sh")
if os.path.exists(upgrade_script):
upgrade_env = os.environ.copy()
if CONFIG.get("python_path"):
upgrade_env["PYTHON"] = CONFIG["python_path"]
upgrade_proc = run_command(
[upgrade_script],
env=upgrade_env,
cwd=CONFIG["netbox_path"],
capture=False,
)
if upgrade_proc.returncode != 0:
raise RuntimeError(f"upgrade.sh fehlgeschlagen (Code {upgrade_proc.returncode}).")
log_success("Upgrade-Skript erfolgreich durchgelaufen.")
else:
log_warn(f"Kein upgrade.sh unter {upgrade_script} gefunden.")
# 5. Services-Restart
log_info("Schritt 5: Starte NetBox-Dienste neu...")
restart_services(strict=True)
print("\n" + "=" * 60)
log_success("RESTORE ERFOLGREICH ABGESCHLOSSEN!")
print("=" * 60)
except Exception as exc:
log_error(f"Fehler beim Restore: {exc}")
finally:
if os.path.exists(tmp_extract_dir):
shutil.rmtree(tmp_extract_dir)
pause()
# ==========================================
# Kernfunktionen: NetBox-Upgrade
# ==========================================
def normalize_version(raw_version):
version = raw_version.strip()
if version.lower().startswith("v"):
version = version[1:]
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
raise ValueError("Bitte eine stabile Version im Format X.Y.Z eingeben, z. B. 4.6.5.")
return version
def version_tuple(version):
return tuple(int(part) for part in version.split("."))
def detect_netbox_version(netbox_root):
"""Versucht die installierte NetBox-Version ohne Django-Start zu erkennen."""
basename = os.path.basename(os.path.realpath(netbox_root))
match = re.search(r"netbox-v?(\d+\.\d+\.\d+)$", basename)
if match:
return match.group(1)
git_dir = os.path.join(netbox_root, ".git")
if os.path.isdir(git_dir):
proc = run_command(
["git", "-C", netbox_root, "describe", "--tags", "--exact-match"],
capture=True,
)
if proc.returncode == 0:
value = proc.stdout.strip().lstrip("v")
if re.fullmatch(r"\d+\.\d+\.\d+", value):
return value
python_bin = os.path.join(netbox_root, "venv", "bin", "python")
package_root = os.path.join(netbox_root, "netbox")
if os.path.isfile(python_bin) and os.path.isdir(package_root):
code = (
"import sys; "
f"sys.path.insert(0, {package_root!r}); "
"import netbox; "
"print(getattr(netbox, '__version__', ''))"
)
proc = run_command([python_bin, "-c", code], capture=True)
value = proc.stdout.strip().lstrip("v") if proc.returncode == 0 else ""
if re.fullmatch(r"\d+\.\d+\.\d+", value):
return value
candidate_files = (
os.path.join(netbox_root, "netbox", "netbox", "version.py"),
os.path.join(netbox_root, "netbox", "netbox", "__init__.py"),
)
pattern = re.compile(r"(?:__version__|VERSION)\s*=\s*['\"]v?(\d+\.\d+\.\d+)['\"]")
for candidate in candidate_files:
if not os.path.isfile(candidate):
continue
try:
with open(candidate, "r", encoding="utf-8") as file_handle:
match = pattern.search(file_handle.read())
if match:
return match.group(1)
except OSError:
pass
return None
def download_release(version, destination):
url = f"https://github.com/netbox-community/netbox/archive/refs/tags/v{version}.tar.gz"
request = urllib.request.Request(
url,
headers={"User-Agent": "netbox-backup-restore-upgrade-tool/1.2"},
)
try:
with urllib.request.urlopen(request, timeout=90) as response, open(destination, "wb") as output:
total = int(response.headers.get("Content-Length", "0") or 0)
downloaded = 0
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
downloaded += len(chunk)
if total:
percent = downloaded * 100 / total
print(f"\r Download: {percent:5.1f}%", end="", flush=True)
if total:
print()
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise RuntimeError(f"NetBox-Release v{version} wurde auf GitHub nicht gefunden.") from exc
raise RuntimeError(f"HTTP-Fehler beim Download: {exc}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Download fehlgeschlagen: {exc.reason}") from exc
def find_extracted_release(staging_dir, version):
expected = os.path.join(staging_dir, f"netbox-{version}")
if os.path.isdir(expected):
return expected
directories = [
os.path.join(staging_dir, name)
for name in os.listdir(staging_dir)
if os.path.isdir(os.path.join(staging_dir, name))
]
if len(directories) == 1:
return directories[0]
raise RuntimeError("Das entpackte NetBox-Verzeichnis konnte nicht eindeutig ermittelt werden.")
def copy_file_preserve(source, destination):
os.makedirs(os.path.dirname(destination), exist_ok=True)
proc = run_command(["cp", "-a", "--", source, destination], capture=True)
if proc.returncode != 0:
raise RuntimeError(f"Kopieren fehlgeschlagen ({source}): {proc.stderr.strip()}")
def copy_directory_contents_preserve(source, destination):
os.makedirs(destination, exist_ok=True)
proc = run_command(["cp", "-a", "--", os.path.join(source, "."), destination], capture=True)
if proc.returncode != 0:
raise RuntimeError(f"Kopieren fehlgeschlagen ({source}): {proc.stderr.strip()}")
def copy_upgrade_data(current_root, new_root):
copied = []
required_files = (
(
os.path.join(current_root, "netbox", "netbox", "configuration.py"),
os.path.join(new_root, "netbox", "netbox", "configuration.py"),
True,
),
(
os.path.join(current_root, "netbox", "netbox", "ldap_config.py"),
os.path.join(new_root, "netbox", "netbox", "ldap_config.py"),
False,
),
(
os.path.join(current_root, "local_requirements.txt"),
os.path.join(new_root, "local_requirements.txt"),
False,
),
(
os.path.join(current_root, "gunicorn.py"),
os.path.join(new_root, "gunicorn.py"),
False,
),
)
for source, destination, mandatory in required_files:
if os.path.isfile(source):
copy_file_preserve(source, destination)
copied.append(os.path.relpath(source, current_root))
elif mandatory:
raise RuntimeError(f"Pflichtdatei fehlt: {source}")
else:
log_info(f" -> Optional nicht vorhanden: {source}")
directory_pairs = (
(
os.path.join(current_root, "netbox", "media"),
os.path.join(new_root, "netbox", "media"),
),
(
os.path.join(current_root, "netbox", "scripts"),
os.path.join(new_root, "netbox", "scripts"),
),
(
os.path.join(current_root, "netbox", "reports"),
os.path.join(new_root, "netbox", "reports"),
),
)
for source, destination in directory_pairs:
if os.path.isdir(source):
copy_directory_contents_preserve(source, destination)
copied.append(os.path.relpath(source, current_root) + "/")
else:
log_info(f" -> Optional nicht vorhanden: {source}")
return copied
def requirement_key(requirement):
value = requirement.strip()
if not value or value.startswith("#") or value.startswith("-"):
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", value, flags=re.IGNORECASE)
if egg_match:
return egg_match.group(1).lower().replace("_", "-")
value = re.split(r"\s+#", value, maxsplit=1)[0].strip()
pep508_match = re.match(r"([A-Za-z0-9_.-]+)\s*@", value)
if pep508_match:
return pep508_match.group(1).lower().replace("_", "-")
package_match = re.match(r"([A-Za-z0-9_.-]+)", value)
if package_match:
return package_match.group(1).lower().replace("_", "-")
return value.lower()
def merge_local_requirements(new_root, include_default_plugins, include_ldap):
requirements_file = os.path.join(new_root, "local_requirements.txt")
existing_lines = []
if os.path.exists(requirements_file):
with open(requirements_file, "r", encoding="utf-8") as file_handle:
existing_lines = file_handle.read().splitlines()
existing_keys = {
key for key in (requirement_key(line) for line in existing_lines) if key is not None
}
desired = []
if include_default_plugins:
desired.extend(DEFAULT_PLUGIN_REQUIREMENTS)
if include_ldap:
desired.append(LDAP_REQUIREMENT)
added = []
for requirement in desired:
key = requirement_key(requirement)
if key in existing_keys:
continue
existing_lines.append(requirement)
existing_keys.add(key)
added.append(requirement)
if desired or os.path.exists(requirements_file):
with open(requirements_file, "w", encoding="utf-8") as file_handle:
content = "\n".join(existing_lines).rstrip()
if content:
file_handle.write(content + "\n")
return added
def has_csrf_setting(configuration_file):
try:
with open(configuration_file, "r", encoding="utf-8") as file_handle:
content = file_handle.read()
except OSError:
return False
return bool(re.search(r"^\s*CSRF_TRUSTED_ORIGINS\s*=", content, flags=re.MULTILINE))
def parse_csrf_origins(raw_value):
origins = []
for value in raw_value.split(","):
origin = value.strip().rstrip("/")
if not origin:
continue
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError(f"Ungültiger CSRF-Origin: {origin}")
if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
raise ValueError(f"CSRF-Origin darf keinen Pfad/Query enthalten: {origin}")
if origin not in origins:
origins.append(origin)
return origins
def append_csrf_setting(configuration_file, origins):
with open(configuration_file, "a", encoding="utf-8") as file_handle:
file_handle.write("\n# Automatisch durch das NetBox-Upgrade-Tool gesetzt\n")
file_handle.write("CSRF_TRUSTED_ORIGINS = [\n")
for origin in origins:
file_handle.write(f" {origin!r},\n")
file_handle.write("]\n")
def stop_services():
log_info("Stoppe NetBox-Dienste für die Umschaltung...")
for service in NETBOX_SERVICES:
proc = run_command(["systemctl", "stop", service], capture=True)
if proc.returncode != 0:
log_warn(f"{service} konnte nicht sauber gestoppt werden: {proc.stderr.strip()}")
status = run_command(["systemctl", "is-active", service], capture=True)
if status.stdout.strip() == "active":
raise RuntimeError(f"Dienst {service} läuft trotz Stop-Befehl weiter.")
def restart_services(strict=False):
failures = []
for service in NETBOX_SERVICES:
proc = run_command(["systemctl", "restart", service], capture=True)
if proc.returncode != 0:
failures.append(f"{service}: {proc.stderr.strip()}")
continue
status = run_command(["systemctl", "is-active", service], capture=True)
if status.stdout.strip() != "active":
failures.append(f"{service}: Status ist '{status.stdout.strip() or 'unbekannt'}'")
else:
log_success(f"Dienst {service} ist aktiv.")
if failures:
message = " | ".join(failures)
if strict:
raise RuntimeError(f"Dienst-Neustart fehlgeschlagen: {message}")
log_warn(f"Dienst-Neustart mit Problemen: {message}")
def switch_installation(netbox_path, extracted_root, version, stamp):
"""Schaltet entweder einen Symlink oder ein echtes /opt/netbox-Verzeichnis um."""
parent = os.path.dirname(netbox_path.rstrip("/")) or "/"
if os.path.islink(netbox_path):
old_link_target = os.readlink(netbox_path)
version_path = os.path.join(parent, f"netbox-{version}")
if os.path.lexists(version_path):
raise RuntimeError(
f"Zielverzeichnis existiert bereits: {version_path}. "
"Bitte zuerst prüfen oder umbenennen."
)
os.rename(extracted_root, version_path)
temporary_link = unique_path(os.path.join(parent, f".netbox-link-{stamp}"))
os.symlink(version_path, temporary_link)
os.replace(temporary_link, netbox_path)
return {
"mode": "symlink",
"netbox_path": netbox_path,
"old_link_target": old_link_target,
"new_path": version_path,
"old_path": os.path.realpath(
old_link_target
if os.path.isabs(old_link_target)
else os.path.join(parent, old_link_target)
),
}
old_path = unique_path(os.path.join(parent, f"netbox_old_{stamp}"))
os.rename(netbox_path, old_path)
try:
os.rename(extracted_root, netbox_path)
except Exception:
os.rename(old_path, netbox_path)
raise
return {
"mode": "directory",
"netbox_path": netbox_path,
"old_path": old_path,
"new_path": netbox_path,
}
def rollback_filesystem_switch(switch_state, stamp):
"""Nur vor Start von upgrade.sh sicher; danach könnten DB-Migrationen erfolgt sein."""
if switch_state["mode"] == "symlink":
parent = os.path.dirname(switch_state["netbox_path"].rstrip("/")) or "/"
temporary_link = unique_path(os.path.join(parent, f".netbox-rollback-{stamp}"))
os.symlink(switch_state["old_link_target"], temporary_link)
os.replace(temporary_link, switch_state["netbox_path"])
return
failed_path = unique_path(f"{switch_state['netbox_path']}_failed_{stamp}")
os.rename(switch_state["netbox_path"], failed_path)
os.rename(switch_state["old_path"], switch_state["netbox_path"])
switch_state["failed_path"] = failed_path
def execute_upgrade():
print_header("NETBOX UPGRADE")
if os.name != "posix" or os.geteuid() != 0:
log_error("Das Upgrade muss unter Linux als root bzw. mit sudo ausgeführt werden.")
pause()
return
netbox_path = os.path.abspath(CONFIG["netbox_path"])
if not os.path.lexists(netbox_path):
log_error(f"NetBox-Pfad existiert nicht: {netbox_path}")
pause()
return
current_root = os.path.realpath(netbox_path)
current_config = os.path.join(current_root, "netbox", "netbox", "configuration.py")
if not os.path.isfile(current_config):
log_error(f"Keine gültige NetBox-Installation erkannt; configuration.py fehlt: {current_config}")
pause()
return
current_version = detect_netbox_version(current_root)
if current_version:
log_info(f"Erkannte installierte Version: v{current_version}")
else:
log_warn("Die installierte NetBox-Version konnte nicht sicher erkannt werden.")
try:
target_version = normalize_version(input("Zielversion (X.Y.Z, optional mit v): "))
except ValueError as exc:
log_error(str(exc))
pause()
return
if current_version:
current_tuple = version_tuple(current_version)
target_tuple = version_tuple(target_version)
if target_tuple <= current_tuple:
log_error(
f"Die Zielversion v{target_version} ist nicht neuer als v{current_version}. "
"Downgrades werden absichtlich nicht automatisiert."
)
pause()
return
if target_tuple[0] > current_tuple[0]:
log_warn(
"Major-Upgrade erkannt. NetBox erlaubt einen Major-Sprung nur von der "
"jeweils letzten Minor-Version des bisherigen Major-Releases."
)
print()
log_warn("Vor jedem Upgrade sind ein aktuelles Datenbank-/Media-Backup und die Release Notes Pflicht.")
log_info(f"Release Notes: https://github.com/netbox-community/netbox/releases/tag/v{target_version}")
if input("Backup erstellt und Release Notes geprüft? (j/N): ").strip().lower() != "j":
log_info("Upgrade abgebrochen. Bitte zuerst Backup und Release Notes prüfen.")
pause()
return
include_plugins = (
input("Genannte Standard-Plugins in local_requirements.txt ergänzen? (J/n): ")
.strip()
.lower()
!= "n"
)
ldap_present = os.path.isfile(
os.path.join(current_root, "netbox", "netbox", "ldap_config.py")
)
if ldap_present:
log_info("LDAP-Konfiguration erkannt; django-auth-ldap wird sichergestellt.")
csrf_present = has_csrf_setting(current_config)
log_info(
"CSRF_TRUSTED_ORIGINS ist in configuration.py vorhanden."
if csrf_present
else "CSRF_TRUSTED_ORIGINS wurde nicht als direkte Zuweisung erkannt."
)
csrf_raw = input(
"Neue CSRF_TRUSTED_ORIGINS kommagetrennt eingeben "
"(leer = vorhandene Konfiguration übernehmen): "
).strip()
try:
csrf_origins = parse_csrf_origins(csrf_raw) if csrf_raw else []
except ValueError as exc:
log_error(str(exc))
pause()
return
if not csrf_present and not csrf_origins:
log_warn(
"Kein CSRF_TRUSTED_ORIGINS erkannt. Die Einstellung kann allerdings auch "
"dynamisch/importiert gesetzt sein."
)
if input("Trotzdem fortfahren? (j/N): ").strip().lower() != "j":
log_info("Upgrade abgebrochen.")
pause()
return
print()
log_warn(
"Plugin-Kompatibilität wird erst während upgrade.sh/Pip geprüft. "
"Nicht kompatible Plugins können das Upgrade stoppen."
)
if input(f"Upgrade auf NetBox v{target_version} jetzt starten? (j/N): ").strip().lower() != "j":
log_info("Upgrade abgebrochen.")
pause()
return
install_parent = os.path.dirname(netbox_path.rstrip("/")) or "/"
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
staging_dir = None
archive_path = None
switch_state = None
services_stopped = False
upgrade_started = False
try:
if not shutil.which("cp"):
raise RuntimeError("Der Befehl 'cp' wurde nicht gefunden.")
if not shutil.which("systemctl"):
raise RuntimeError("Der Befehl 'systemctl' wurde nicht gefunden.")
os.makedirs(CONFIG["backup_dir"], exist_ok=True)
fd, archive_path = tempfile.mkstemp(
prefix=f"netbox-v{target_version}-",
suffix=".tar.gz",
dir=CONFIG["backup_dir"],
)
os.close(fd)
staging_dir = tempfile.mkdtemp(prefix=".netbox-upgrade-", dir=install_parent)
log_info(f"Schritt 1: Lade NetBox v{target_version} herunter...")
download_release(target_version, archive_path)
log_success("Release-Archiv heruntergeladen.")
log_info("Schritt 2: Entpacke und prüfe Release-Archiv...")
with tarfile.open(archive_path, "r:gz") as tarf:
safe_extract_tar(tarf, staging_dir)
extracted_root = find_extracted_release(staging_dir, target_version)
required_upgrade_script = os.path.join(extracted_root, "upgrade.sh")
if not os.path.isfile(required_upgrade_script):
raise RuntimeError("Das Release enthält kein upgrade.sh und ist nicht plausibel.")
log_success("Release-Archiv erfolgreich geprüft.")
log_info("Schritt 3: Übernehme Konfiguration, Media, Skripte und Reports...")
copied = copy_upgrade_data(current_root, extracted_root)
for item in copied:
log_info(f" -> übernommen: {item}")
added_requirements = merge_local_requirements(
extracted_root,
include_default_plugins=include_plugins,
include_ldap=ldap_present,
)
if added_requirements:
for requirement in added_requirements:
log_info(f" -> Requirement ergänzt: {requirement}")
else:
log_info(" -> Keine neuen Requirements erforderlich.")
new_configuration = os.path.join(
extracted_root, "netbox", "netbox", "configuration.py"
)
if csrf_origins:
append_csrf_setting(new_configuration, csrf_origins)
log_success("CSRF_TRUSTED_ORIGINS wurde in der neuen Konfiguration gesetzt.")
elif has_csrf_setting(new_configuration):
log_success("Vorhandenes CSRF_TRUSTED_ORIGINS wurde übernommen.")
else:
log_warn("CSRF_TRUSTED_ORIGINS bleibt unverändert bzw. dynamisch konfiguriert.")
log_info("Schritt 4: Stoppe Dienste und schalte auf die neue Version um...")
stop_services()
services_stopped = True
switch_state = switch_installation(
netbox_path,
extracted_root,
target_version,
stamp,
)
log_success("NetBox-Verzeichnis wurde umgeschaltet.")
log_info("Schritt 5: Führe /opt/netbox/upgrade.sh aus...")
upgrade_script = os.path.join(netbox_path, "upgrade.sh")
upgrade_env = os.environ.copy()
if CONFIG.get("python_path"):
if not os.path.isfile(CONFIG["python_path"]):
raise RuntimeError(
f"Konfigurierter Python-Pfad existiert nicht: {CONFIG['python_path']}"
)
upgrade_env["PYTHON"] = CONFIG["python_path"]
log_info(f"Verwende PYTHON={CONFIG['python_path']}")
upgrade_started = True
upgrade_proc = run_command(
[upgrade_script],
env=upgrade_env,
cwd=netbox_path,
capture=False,
)
if upgrade_proc.returncode != 0:
raise RuntimeError(
f"NetBox upgrade.sh wurde mit Code {upgrade_proc.returncode} beendet."
)
log_success("upgrade.sh wurde erfolgreich abgeschlossen.")
log_info("Schritt 6: Starte NetBox-Dienste neu und prüfe den Status...")
restart_services(strict=True)
services_stopped = False
print("\n" + "=" * 60)
log_success(f"UPGRADE AUF NETBOX v{target_version} ERFOLGREICH!")
if switch_state["mode"] == "directory":
log_info(f"Alte Installation verbleibt unter: {switch_state['old_path']}")
else:
log_info(f"Vorheriges Release verbleibt unter: {switch_state['old_path']}")
log_info(f"Aktives Release: {switch_state['new_path']}")
print("=" * 60)
except Exception as exc:
log_error(f"Upgrade fehlgeschlagen: {exc}")
if switch_state and not upgrade_started:
log_warn("Fehler trat vor Start von upgrade.sh auf; Dateisystem-Umschaltung wird zurückgenommen.")
try:
rollback_filesystem_switch(switch_state, stamp)
log_success("Vorherige Installation wurde wieder aktiviert.")
if services_stopped:
restart_services(strict=False)
services_stopped = False
except Exception as rollback_exc:
log_error(f"Automatischer Dateisystem-Rollback fehlgeschlagen: {rollback_exc}")
elif switch_state and upgrade_started:
log_error(
"Kein automatischer Rollback nach Start von upgrade.sh: "
"Datenbank-Migrationen könnten bereits angewendet worden sein."
)
log_info(f"Vorherige Installation liegt weiterhin unter: {switch_state['old_path']}")
log_info("Für einen vollständigen Rollback Datenbank/Media aus dem Backup wiederherstellen.")
elif services_stopped:
restart_services(strict=False)
services_stopped = False
finally:
if archive_path and os.path.exists(archive_path):
os.remove(archive_path)
if staging_dir and os.path.exists(staging_dir):
shutil.rmtree(staging_dir, ignore_errors=True)
pause()
# ==========================================
# Einstellungen
# ==========================================
def settings_menu():
while True:
print_header("EINSTELLUNGEN")
keys = list(CONFIG.keys())
for index, key in enumerate(keys, 1):
value = "*****" if key == "db_pass" and CONFIG[key] else CONFIG[key]
print(
f" {Colors.BOLD}{index}.{Colors.ENDC} "
f"{key.ljust(15)}: {Colors.BLUE}{value}{Colors.ENDC}"
)
print(f"\n {Colors.BOLD}S.{Colors.ENDC} Speichern & zurück zum Hauptmenü")
choice = input("\nWelchen Wert möchtest du ändern? (Zahl/S): ").strip().lower()
if choice == "s":
break
if choice.isdigit() and 1 <= int(choice) <= len(keys):
key_to_edit = keys[int(choice) - 1]
current_display = "*****" if key_to_edit == "db_pass" and CONFIG[key_to_edit] else CONFIG[key_to_edit]
new_value = input(f"Neuer Wert für {key_to_edit} (aktuell: {current_display}): ")
if new_value.strip() != "":
CONFIG[key_to_edit] = new_value.strip()
# ==========================================
# Hauptmenü
# ==========================================
def main_menu():
while True:
print_header("NETBOX BACKUP, RESTORE & UPGRADE TOOL")
print(f" {Colors.BOLD}1.{Colors.ENDC} Backup erstellen")
print(f" {Colors.BOLD}2.{Colors.ENDC} Backup wiederherstellen")
print(f" {Colors.BOLD}3.{Colors.ENDC} NetBox upgraden")
print(f" {Colors.BOLD}4.{Colors.ENDC} Einstellungen prüfen / anpassen")
print(f" {Colors.BOLD}5.{Colors.ENDC} Beenden\n")
choice = input(Colors.BOLD + "Wähle eine Aktion (1-5): " + Colors.ENDC).strip()
if choice == "1":
execute_backup()
elif choice == "2":
execute_restore()
elif choice == "3":
execute_upgrade()
elif choice == "4":
settings_menu()
elif choice == "5":
clear_screen()
print("Beendet. Auf Wiedersehen!\n")
sys.exit(0)
else:
log_error("Ungültige Eingabe.")
time.sleep(1)
if __name__ == "__main__":
if os.geteuid() != 0:
print(
Colors.WARNING
+ "Hinweis: Dieses Skript sollte idealerweise als 'root' oder mit 'sudo' ausgeführt werden,"
+ Colors.ENDC
)
print(
Colors.WARNING
+ "da systemctl, chown und der Zugriff auf /opt/netbox erhöhte Rechte erfordern.\n"
+ Colors.ENDC
)
time.sleep(2)
try:
main_menu()
except KeyboardInterrupt:
print("\n\nAbbruch durch Benutzer. Ciao!")
sys.exit(0)