#!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' readonly REPOSITORY_BASE="https://git.mrblake.cc/MrBlake/Netbox-Store" readonly CONFIG_BEGIN="# BEGIN NETBOX PLUGIN STORE (managed by install.sh)" readonly CONFIG_END="# END NETBOX PLUGIN STORE (managed by install.sh)" info() { printf '\n\033[1;36m==> %s\033[0m\n' "$*" } warn() { printf '\n\033[1;33mWARNUNG: %s\033[0m\n' "$*" >&2 } die() { printf '\n\033[1;31mFEHLER: %s\033[0m\n' "$*" >&2 exit 1 } prompt_default() { local prompt="$1" local default="$2" local answer read -r -p "${prompt} [${default}]: " answer printf '%s' "${answer:-$default}" } confirm() { local prompt="$1" local answer read -r -p "${prompt} [j/N]: " answer case "${answer,,}" in j|ja|y|yes) return 0 ;; *) return 1 ;; esac } require_root() { [[ "${EUID}" -eq 0 ]] || die "Bitte mit sudo ausführen: sudo bash install.sh" [[ "$(uname -s)" == "Linux" ]] || die "Der Host-Agent wird ausschließlich unter Linux unterstützt." [[ -t 0 ]] || die "Der Installer benötigt für die Sicherheitsabfragen ein interaktives Terminal." } validate_path() { [[ "$1" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "Ungültiger absoluter Pfad: $1" } validate_account() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_-]*\$?$ ]] || die "Ungültiges Dienstkonto: $1" id "$1" >/dev/null 2>&1 || die "Das Dienstkonto '$1' existiert nicht." } ensure_os_tools() { local missing=() local command_name for command_name in curl python3 systemctl unshare setpriv runuser; do command -v "$command_name" >/dev/null 2>&1 || missing+=("$command_name") done if (( ${#missing[@]} == 0 )); then return fi command -v apt-get >/dev/null 2>&1 \ || die "Fehlende Werkzeuge (${missing[*]}) und kein apt-get verfügbar." warn "Fehlende Systemwerkzeuge werden jetzt über apt installiert: ${missing[*]}" apt-get update apt-get install -y python3-venv util-linux curl } download() { local url="$1" local destination="$2" curl --fail --silent --show-error --location \ --proto '=https' --tlsv1.2 \ "$url" --output "$destination" } backup_file() { local path="$1" if [[ -e "$path" ]]; then cp -a -- "$path" "$BACKUP_DIR/$(basename "$path")" fi } detect_netbox_version() { local detected="" detected=$("$NETBOX_PYTHON" "$MANAGE_PATH" shell -c \ 'from django.conf import settings; print(settings.VERSION)' 2>/dev/null | tail -n 1 || true) if [[ "$detected" =~ ^4\.6\.[5-8]$ ]]; then printf '%s' "$detected" else printf '%s' "4.6.8" fi } resolve_repository_commit() { "$NETBOX_PYTHON" - <<'PY' import json import re from urllib.request import Request, urlopen request = Request( "https://git.mrblake.cc/api/v1/repos/MrBlake/Netbox-Store/branches/main", headers={"Accept": "application/json", "User-Agent": "netbox-store-installer/1"}, ) with urlopen(request, timeout=15) as response: payload = json.load(response) commit = str(payload.get("commit", {}).get("id", "")).lower() if not re.fullmatch(r"[a-f0-9]{40}", commit): raise SystemExit("Der main-Commit des Installationsrepositorys konnte nicht sicher ermittelt werden.") print(commit) PY } validate_store_url() { STORE_URL_VALUE="$1" "$NETBOX_PYTHON" - <<'PY' import os from urllib.parse import urlsplit value = os.environ["STORE_URL_VALUE"].rstrip("/") parsed = urlsplit(value) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment ): raise SystemExit("Die Store-URL muss eine credential-freie HTTPS-URL ohne Query oder Fragment sein.") print(value) PY } update_requirements() { REQUIREMENTS_PATH_VALUE="$LOCAL_REQUIREMENTS" \ STORE_REQUIREMENT_VALUE="$STORE_PLUGIN_REQUIREMENT" \ STORE_INCLUDE_VALUE="-r $STORE_REQUIREMENTS" \ "$NETBOX_PYTHON" - <<'PY' import os import re import stat import tempfile from pathlib import Path path = Path(os.environ["REQUIREMENTS_PATH_VALUE"]) requirement = os.environ["STORE_REQUIREMENT_VALUE"] include = os.environ["STORE_INCLUDE_VALUE"] text = path.read_text(encoding="utf-8") if path.exists() else "" lines = [ line for line in text.splitlines() if not re.match(r"^\s*netbox-plugin-store\s*@", line) and line.strip() != include ] lines.extend((requirement, include)) content = "\n".join(lines).rstrip() + "\n" path.parent.mkdir(parents=True, exist_ok=True) metadata = path.stat() if path.exists() else None fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) if metadata is not None: os.chmod(temporary_name, stat.S_IMODE(metadata.st_mode)) if hasattr(os, "chown"): os.chown(temporary_name, metadata.st_uid, metadata.st_gid) else: os.chmod(temporary_name, 0o644) os.replace(temporary_name, path) finally: if os.path.exists(temporary_name): os.unlink(temporary_name) PY } update_agent_config() { AGENT_CONFIG_VALUE="$AGENT_CONFIG" \ AGENT_DRY_RUN_VALUE="$AGENT_DRY_RUN" \ NETBOX_UID_VALUE="$NETBOX_UID" \ NETBOX_GID_VALUE="$NETBOX_GID" \ STORE_URL_VALUE="$STORE_URL" \ NETBOX_ROOT_VALUE="$NETBOX_ROOT" \ NETBOX_VERSION_VALUE="$NETBOX_VERSION" \ PYTHON_PATH_VALUE="$NETBOX_PYTHON" \ MANAGE_PATH_VALUE="$MANAGE_PATH" \ SYSTEMCTL_PATH_VALUE="$(command -v systemctl)" \ UNSHARE_PATH_VALUE="$(command -v unshare)" \ SETPRIV_PATH_VALUE="$(command -v setpriv)" \ "$NETBOX_PYTHON" - <<'PY' import json import os import re from pathlib import Path from urllib.parse import urlsplit path = Path(os.environ["AGENT_CONFIG_VALUE"]) store_url = os.environ["STORE_URL_VALUE"].rstrip("/") store_host = urlsplit(store_url).hostname allowed_hosts = list(dict.fromkeys((store_host, "git.mrblake.cc", "github.com", "codeload.github.com"))) root = Path(os.environ["NETBOX_ROOT_VALUE"]) updates = { ("agent", "dry_run"): os.environ["AGENT_DRY_RUN_VALUE"], ("agent", "allowed_peer_uids"): f'[{os.environ["NETBOX_UID_VALUE"]}]', ("agent", "allowed_peer_gids"): f'[{os.environ["NETBOX_GID_VALUE"]}]', ("agent", "socket_gid"): os.environ["NETBOX_GID_VALUE"], ("store", "base_url"): json.dumps(store_url), ("store", "allowed_hosts"): json.dumps(allowed_hosts), ("paths", "allowed_root"): json.dumps(str(root)), ("paths", "include_path"): json.dumps(str(root / "netbox/netbox/store_plugins.py")), ("paths", "requirements_path"): json.dumps(str(root / "local_requirements_store.txt")), ("commands", "python_path"): json.dumps(os.environ["PYTHON_PATH_VALUE"]), ("commands", "manage_path"): json.dumps(os.environ["MANAGE_PATH_VALUE"]), ("commands", "systemctl_path"): json.dumps(os.environ["SYSTEMCTL_PATH_VALUE"]), ("commands", "unshare_path"): json.dumps(os.environ["UNSHARE_PATH_VALUE"]), ("commands", "setpriv_path"): json.dumps(os.environ["SETPRIV_PATH_VALUE"]), ("policy", "netbox_version"): json.dumps(os.environ["NETBOX_VERSION_VALUE"]), } lines = path.read_text(encoding="utf-8").splitlines() section = "" seen = set() result = [] for line in lines: match = re.match(r"^\s*\[([A-Za-z0-9_-]+)]\s*$", line) if match: section = match.group(1) key_match = re.match(r"^(\s*)([A-Za-z0-9_]+)\s*=", line) key = (section, key_match.group(2)) if key_match else None if key in updates: result.append(f"{key_match.group(1)}{key[1]} = {updates[key]}") seen.add(key) else: result.append(line) missing = sorted(set(updates) - seen) if missing: raise SystemExit("Agent-Vorlage enthält erwartete Schlüssel nicht: " + ", ".join(f"{s}.{k}" for s, k in missing)) path.write_text("\n".join(result) + "\n", encoding="utf-8") PY chmod 0600 "$AGENT_CONFIG" } update_netbox_configuration() { CONFIG_PATH_VALUE="$CONFIGURATION_PATH" \ STORE_URL_VALUE="$STORE_URL" \ EXECUTION_MODE_VALUE="$EXECUTION_MODE" \ DEFAULT_DRY_RUN_VALUE="$DEFAULT_DRY_RUN" \ CONFIG_BEGIN_VALUE="$CONFIG_BEGIN" \ CONFIG_END_VALUE="$CONFIG_END" \ "$NETBOX_PYTHON" - <<'PY' import os import re import stat import tempfile from pathlib import Path path = Path(os.environ["CONFIG_PATH_VALUE"]) start_marker = os.environ["CONFIG_BEGIN_VALUE"] end_marker = os.environ["CONFIG_END_VALUE"] store_url = os.environ["STORE_URL_VALUE"].rstrip("/") execution_mode = os.environ["EXECUTION_MODE_VALUE"] default_dry_run = os.environ["DEFAULT_DRY_RUN_VALUE"] == "True" block = "\n".join([ start_marker, "from netbox.store_plugins import STORE_PLUGINS", "", "PLUGINS = list(globals().get(\"PLUGINS\", []))", "if \"netbox_plugin_store\" not in PLUGINS:", " PLUGINS.append(\"netbox_plugin_store\")", "for _store_plugin in STORE_PLUGINS:", " if _store_plugin not in PLUGINS:", " PLUGINS.append(_store_plugin)", "", "PLUGINS_CONFIG = dict(globals().get(\"PLUGINS_CONFIG\", {}))", "PLUGINS_CONFIG[\"netbox_plugin_store\"] = {", f" \"store_url\": {store_url!r},", f" \"allowed_store_urls\": [{store_url!r}],", " \"allowed_artifact_urls\": [", f" {store_url!r},", " \"https://git.mrblake.cc\",", " \"https://github.com\",", " \"https://codeload.github.com\",", " ],", f" \"execution_mode\": {execution_mode!r},", " \"agent_socket_path\": \"/run/netbox-store-agent/agent.sock\",", " \"agent_timeout\": 30,", f" \"default_dry_run\": {default_dry_run!r},", "}", end_marker, ]) original = path.read_text(encoding="utf-8") original = re.sub( r"(?m)^from store_plugins import STORE_PLUGINS\s*$", "from netbox.store_plugins import STORE_PLUGINS", original, ) start_count = original.count(start_marker) end_count = original.count(end_marker) if (start_count, end_count) == (0, 0): candidate = original.rstrip() + "\n\n" + block + "\n" elif (start_count, end_count) == (1, 1): before, remainder = original.split(start_marker, 1) _, after = remainder.split(end_marker, 1) candidate = before.rstrip() + "\n\n" + block + after.rstrip() + "\n" else: raise SystemExit("Der verwaltete configuration.py-Block ist unvollständig oder doppelt vorhanden.") compile(candidate, str(path), "exec") metadata = path.stat() fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: handle.write(candidate) handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, stat.S_IMODE(metadata.st_mode)) if hasattr(os, "chown"): os.chown(temporary_name, metadata.st_uid, metadata.st_gid) os.replace(temporary_name, path) finally: if os.path.exists(temporary_name): os.unlink(temporary_name) PY } require_root printf '\nNetBox Plugin Store – interaktiver Installer\n' printf '=============================================\n' printf ' 1) Sicherer Testmodus (keine Systemänderungen durch Lifecycle-Aktionen)\n' printf ' 2) Produktionsmodus (echte Installationen über den Host-Agent)\n' printf ' 3) Abbrechen\n\n' read -r -p "Auswahl [1]: " MODE_CHOICE MODE_CHOICE="${MODE_CHOICE:-1}" case "$MODE_CHOICE" in 1) EXECUTION_MODE="dry_run" AGENT_DRY_RUN="true" DEFAULT_DRY_RUN="True" MODE_LABEL="Sicherer Testmodus" ;; 2) EXECUTION_MODE="agent" AGENT_DRY_RUN="false" DEFAULT_DRY_RUN="False" MODE_LABEL="Produktionsmodus mit echten Lifecycle-Änderungen" ;; 3) exit 0 ;; *) die "Ungültige Menüauswahl." ;; esac NETBOX_ROOT=$(prompt_default "NetBox-Installationspfad" "/opt/netbox") validate_path "$NETBOX_ROOT" [[ -d "$NETBOX_ROOT" ]] || die "NetBox-Pfad wurde nicht gefunden: $NETBOX_ROOT" NETBOX_PYTHON="$NETBOX_ROOT/venv/bin/python" MANAGE_PATH="$NETBOX_ROOT/netbox/manage.py" CONFIGURATION_PATH="$NETBOX_ROOT/netbox/netbox/configuration.py" LOCAL_REQUIREMENTS="$NETBOX_ROOT/local_requirements.txt" STORE_PLUGINS_FILE="$NETBOX_ROOT/netbox/netbox/store_plugins.py" STORE_REQUIREMENTS="$NETBOX_ROOT/local_requirements_store.txt" [[ -x "$NETBOX_PYTHON" ]] || die "NetBox-Python wurde nicht gefunden: $NETBOX_PYTHON" [[ -f "$MANAGE_PATH" ]] || die "manage.py wurde nicht gefunden: $MANAGE_PATH" [[ -f "$CONFIGURATION_PATH" ]] || die "configuration.py wurde nicht gefunden: $CONFIGURATION_PATH" DETECTED_USER=$(systemctl show netbox.service --property=User --value 2>/dev/null || true) DETECTED_USER="${DETECTED_USER:-netbox}" NETBOX_USER=$(prompt_default "Linux-Dienstkonto von NetBox" "$DETECTED_USER") validate_account "$NETBOX_USER" NETBOX_GROUP=$(id -gn "$NETBOX_USER") NETBOX_UID=$(id -u "$NETBOX_USER") NETBOX_GID=$(id -g "$NETBOX_USER") NETBOX_VERSION=$(prompt_default "Installierte NetBox-Version" "$(detect_netbox_version)") [[ "$NETBOX_VERSION" =~ ^4\.6\.[5-8]$ ]] \ || die "Unterstützt werden ausschließlich NetBox 4.6.5 bis 4.6.8." STORE_URL=$(prompt_default "Store-URL" "https://netbox.mrblake.cc") STORE_URL=$(validate_store_url "$STORE_URL") SOURCE_COMMIT=$(resolve_repository_commit) SOURCE_ARCHIVE="${REPOSITORY_BASE}/archive/${SOURCE_COMMIT}.tar.gz" RAW_BASE="${REPOSITORY_BASE}/raw/commit/${SOURCE_COMMIT}" STORE_PLUGIN_REQUIREMENT="netbox-plugin-store @ ${SOURCE_ARCHIVE}#subdirectory=netbox_plugin" AGENT_REQUIREMENT="mrblake-netbox-store-agent @ ${SOURCE_ARCHIVE}#subdirectory=host_agent" readonly SOURCE_COMMIT SOURCE_ARCHIVE RAW_BASE STORE_PLUGIN_REQUIREMENT AGENT_REQUIREMENT printf '\nZusammenfassung\n' printf ' Modus: %s\n' "$MODE_LABEL" printf ' NetBox: %s (%s)\n' "$NETBOX_ROOT" "$NETBOX_VERSION" printf ' Dienstkonto: %s (UID %s, GID %s, Gruppe %s)\n' \ "$NETBOX_USER" "$NETBOX_UID" "$NETBOX_GID" "$NETBOX_GROUP" printf ' Store: %s\n' "$STORE_URL" printf ' Installer-Commit: %s\n' "$SOURCE_COMMIT" printf ' Konfiguration: %s\n' "$CONFIGURATION_PATH" confirm "Installation mit diesen Werten starten?" || exit 0 if [[ "$MODE_CHOICE" == "2" ]]; then warn "Der Produktionsmodus darf Python-Pakete installieren, NetBox-Dateien ändern und Dienste neu starten." read -r -p "Zum Fortfahren exakt ECHT INSTALLIEREN eingeben: " PRODUCTION_CONFIRMATION [[ "$PRODUCTION_CONFIRMATION" == "ECHT INSTALLIEREN" ]] || die "Produktionsfreigabe wurde nicht erteilt." fi ensure_os_tools TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)-${BASHPID}" BACKUP_DIR="/var/backups/netbox-plugin-store/$TIMESTAMP" mkdir -p "$BACKUP_DIR" chmod 0700 "$BACKUP_DIR" backup_file "$CONFIGURATION_PATH" backup_file "$LOCAL_REQUIREMENTS" backup_file "/etc/netbox-store-agent/agent.toml" backup_file "/etc/systemd/system/netbox-store-agent.service" backup_file "/etc/systemd/system/netbox-store-agent.socket" info "Backups: $BACKUP_DIR" TMP_DIR=$(mktemp -d /tmp/netbox-store-install.XXXXXXXX) cleanup() { if [[ -n "${TMP_DIR:-}" && "$TMP_DIR" == /tmp/netbox-store-install.* ]]; then rm -rf -- "$TMP_DIR" fi } trap cleanup EXIT trap 'warn "Installation in Zeile $LINENO abgebrochen. Backups liegen unter $BACKUP_DIR."' ERR info "NetBox-Plugin installieren" "$NETBOX_PYTHON" -m pip install --no-cache-dir --no-deps --upgrade --force-reinstall \ "$STORE_PLUGIN_REQUIREMENT" update_requirements info "Host-Agent installieren" if [[ ! -x /opt/netbox-store-agent/venv/bin/python ]]; then python3 -m venv /opt/netbox-store-agent/venv fi /opt/netbox-store-agent/venv/bin/python -m pip install --upgrade pip /opt/netbox-store-agent/venv/bin/python -m pip install --no-cache-dir --upgrade --force-reinstall \ "$AGENT_REQUIREMENT" install -d -o root -g root -m 0750 /etc/netbox-store-agent AGENT_CONFIG="/etc/netbox-store-agent/agent.toml" if [[ ! -f "$AGENT_CONFIG" ]]; then download "$RAW_BASE/host_agent/examples/agent.toml" "$TMP_DIR/agent.toml" install -o root -g root -m 0600 "$TMP_DIR/agent.toml" "$AGENT_CONFIG" fi update_agent_config download "$RAW_BASE/host_agent/systemd/netbox-store-agent.service" "$TMP_DIR/netbox-store-agent.service" download "$RAW_BASE/host_agent/systemd/netbox-store-agent.socket" "$TMP_DIR/netbox-store-agent.socket" sed -i \ -e 's#/usr/local/bin/netbox-store-agent#/opt/netbox-store-agent/venv/bin/netbox-store-agent#' \ -e "s#ReadWritePaths=/opt/netbox #ReadWritePaths=$NETBOX_ROOT #" \ "$TMP_DIR/netbox-store-agent.service" sed -i "s/^SocketGroup=.*/SocketGroup=$NETBOX_GROUP/" "$TMP_DIR/netbox-store-agent.socket" install -o root -g root -m 0644 "$TMP_DIR/netbox-store-agent.service" \ /etc/systemd/system/netbox-store-agent.service install -o root -g root -m 0644 "$TMP_DIR/netbox-store-agent.socket" \ /etc/systemd/system/netbox-store-agent.socket info "Verwaltete NetBox-Dateien vorbereiten" if [[ ! -f "$STORE_PLUGINS_FILE" ]]; then printf 'STORE_PLUGINS = []\n' > "$TMP_DIR/store_plugins.py" install -o root -g root -m 0644 "$TMP_DIR/store_plugins.py" "$STORE_PLUGINS_FILE" fi if [[ ! -f "$STORE_REQUIREMENTS" ]]; then printf '# Generated by netbox-store-agent. Do not edit.\n' > "$TMP_DIR/local_requirements_store.txt" install -o root -g root -m 0644 "$TMP_DIR/local_requirements_store.txt" "$STORE_REQUIREMENTS" fi chmod 0644 "$STORE_PLUGINS_FILE" "$STORE_REQUIREMENTS" update_netbox_configuration info "Konfiguration validieren" /opt/netbox-store-agent/venv/bin/netbox-store-agent \ --config "$AGENT_CONFIG" validate-config "$NETBOX_PYTHON" -m py_compile "$CONFIGURATION_PATH" "$STORE_PLUGINS_FILE" info "Datenbank und statische Dateien aktualisieren" "$NETBOX_PYTHON" "$MANAGE_PATH" migrate --no-input "$NETBOX_PYTHON" "$MANAGE_PATH" collectstatic --no-input info "Dienste aktivieren und neu starten" systemctl daemon-reload systemctl enable netbox-store-agent.socket systemctl restart netbox-store-agent.socket systemctl restart netbox-store-agent.service systemctl restart netbox netbox-rq info "Socket-Zugriff als NetBox-Dienstkonto prüfen" runuser -u "$NETBOX_USER" -- \ /opt/netbox-store-agent/venv/bin/netbox-store-agent capabilities printf '\n\033[1;32mInstallation abgeschlossen.\033[0m\n' printf 'Backup: %s\n' "$BACKUP_DIR" if [[ "$MODE_CHOICE" == "1" ]]; then printf 'Lifecycle-Aktionen bleiben im Dry-Run-Modus und verändern das System nicht.\n' else printf 'Echte Lifecycle-Aktionen sind freigeschaltet. Prüfe im Dialog, dass Dry-Run nicht markiert ist.\n' printf 'Neue Plugins werden zuerst installiert und bleiben deaktiviert; aktiviere sie anschließend separat.\n' fi