diff --git a/README.md b/README.md index d0e5654..f10867b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Dieses Repository besteht aus drei getrennten Komponenten: | `netbox_plugin/` | NetBox-Oberfläche für Katalog, Installation und Plugin-Lebenszyklus. Unterstützt NetBox 4.6.5 bis 4.6.8. | | `host_agent/` | Kleine privilegierte Linux-Komponente, die freigegebene Artefakte prüft, Source-Archive lokal als Wheel baut und die Änderungen am NetBox-Host ausführt. | +Für NetBox-Hosts steht zusätzlich [`install.sh`](install.sh) bereit. Der interaktive Installer kann Plugin, Host-Agent, systemd-Socket, Requirements und den verwalteten Block in `configuration.py` in einem Durchlauf einrichten. Er startet standardmäßig im Dry-Run-Modus, legt vor Änderungen Backups an und verlangt für echte Lifecycle-Änderungen eine zusätzliche ausdrückliche Bestätigung. + Der Store verwendet standardmäßig eine lokale JSON-Datei. MariaDB ist optional. Die konfigurierte Standardquelle gilt als vom Betreiber freigegeben; weitere Quellen werden im Admin-Bereich zunächst ausstehend angelegt. Automatisch erkannte Plugins und Releases erscheinen erst nach einer ausdrücklichen Freigabe im öffentlichen Katalog. ## Native Installation mit Apache und JSON @@ -269,7 +271,7 @@ Kurzablauf auf dem Linux-NetBox-Host: 4. Die vom Agent verwaltete Plugin-Liste einmalig in `configuration.py` einbinden: ```python - from store_plugins import STORE_PLUGINS + from netbox.store_plugins import STORE_PLUGINS PLUGINS += STORE_PLUGINS ``` diff --git a/host_agent/README.md b/host_agent/README.md index d035082..5f87d75 100644 --- a/host_agent/README.md +++ b/host_agent/README.md @@ -32,7 +32,7 @@ The agent owns only these two configured files: In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration: ```python -from store_plugins import STORE_PLUGINS +from netbox.store_plugins import STORE_PLUGINS PLUGINS += STORE_PLUGINS ``` diff --git a/host_agent/pyproject.toml b/host_agent/pyproject.toml index 4d1de49..6014943 100644 --- a/host_agent/pyproject.toml +++ b/host_agent/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mrblake-netbox-store-agent" -version = "0.1.1" +version = "0.1.2" description = "Fail-closed host agent for curated NetBox plugin lifecycle operations" readme = "README.md" requires-python = ">=3.11" diff --git a/host_agent/src/netbox_store_agent/executor.py b/host_agent/src/netbox_store_agent/executor.py index e3bb54d..2ba2054 100644 --- a/host_agent/src/netbox_store_agent/executor.py +++ b/host_agent/src/netbox_store_agent/executor.py @@ -260,7 +260,11 @@ class OperationProcessor: temp_root = self.config.paths.temp_dir if temp_root.is_symlink(): raise PolicyError("temporary directory may not be a symlink") - temp_root.mkdir(parents=True, exist_ok=True, mode=0o700) + temp_root.mkdir(parents=True, exist_ok=True, mode=0o711) + # The dropped-privilege source builder needs traversal to its own + # 0700 operation directory. It cannot list this root, the journal, + # or the separately protected backup directory. + os.chmod(temp_root, 0o711) with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary: self._event(operation_id, "artifact", "Downloading and verifying approved artifact") artifact = self.store.download_release(plan, Path(temporary)) diff --git a/host_agent/systemd/netbox-store-agent.service b/host_agent/systemd/netbox-store-agent.service index ae3973b..9465dcc 100644 --- a/host_agent/systemd/netbox-store-agent.service +++ b/host_agent/systemd/netbox-store-agent.service @@ -10,9 +10,9 @@ User=root Group=root ExecStart=/usr/local/bin/netbox-store-agent --config /etc/netbox-store-agent/agent.toml daemon StateDirectory=netbox-store-agent -StateDirectoryMode=0700 +StateDirectoryMode=0711 RuntimeDirectory=netbox-store-agent -RuntimeDirectoryMode=0750 +RuntimeDirectoryMode=0755 UMask=0077 NoNewPrivileges=true PrivateTmp=true diff --git a/host_agent/systemd/netbox-store-agent.socket b/host_agent/systemd/netbox-store-agent.socket index 10c6c23..b517d6f 100644 --- a/host_agent/systemd/netbox-store-agent.socket +++ b/host_agent/systemd/netbox-store-agent.socket @@ -7,7 +7,7 @@ SocketMode=0660 SocketUser=root # Replace with the group of the NetBox service. SocketGroup=netbox -DirectoryMode=0750 +DirectoryMode=0755 RemoveOnStop=true [Install] diff --git a/host_agent/tests/test_executor.py b/host_agent/tests/test_executor.py index 74a3075..32f78ad 100644 --- a/host_agent/tests/test_executor.py +++ b/host_agent/tests/test_executor.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import stat import tempfile import unittest from pathlib import Path @@ -56,6 +58,8 @@ class ExecutorTests(unittest.TestCase): self.assertEqual(runner.commands, []) self.assertIsNone(journal.get_managed_plugin("demo-plugin")) self.assertFalse(config.paths.include_path.exists()) + if os.name == "posix": + self.assertEqual(stat.S_IMODE(config.paths.temp_dir.stat().st_mode), 0o711) def test_install_is_disabled_and_uses_fixed_pip_argv(self) -> None: config = make_config(self.root, dry_run=False) diff --git a/host_agent/tests/test_systemd_units.py b/host_agent/tests/test_systemd_units.py new file mode 100644 index 0000000..16a9438 --- /dev/null +++ b/host_agent/tests/test_systemd_units.py @@ -0,0 +1,22 @@ +import unittest +from pathlib import Path + + +class SystemdUnitTests(unittest.TestCase): + def test_socket_directory_is_traversable_but_socket_remains_restricted(self): + socket_unit = Path("systemd/netbox-store-agent.socket").read_text(encoding="utf-8") + + self.assertIn("DirectoryMode=0755", socket_unit) + self.assertIn("SocketMode=0660", socket_unit) + self.assertIn("SocketGroup=netbox", socket_unit) + + def test_state_traversal_does_not_relax_service_umask(self): + service_unit = Path("systemd/netbox-store-agent.service").read_text(encoding="utf-8") + + self.assertIn("StateDirectoryMode=0711", service_unit) + self.assertIn("RuntimeDirectoryMode=0755", service_unit) + self.assertIn("UMask=0077", service_unit) + + +if __name__ == "__main__": + unittest.main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0d646e4 --- /dev/null +++ b/install.sh @@ -0,0 +1,502 @@ +#!/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 diff --git a/store/templates/installation.php b/store/templates/installation.php index 973663f..b6d6163 100644 --- a/store/templates/installation.php +++ b/store/templates/installation.php @@ -8,6 +8,13 @@
+

Interaktive Komplettinstallation

+

Der Installer richtet NetBox-Plugin, Host-Agent, systemd-Socket, Requirements und den markierten Konfigurationsblock gemeinsam ein. Er fragt Pfade, Dienstkonto, NetBox-Version und Betriebsmodus ab, zeigt vorab eine Zusammenfassung und erstellt Sicherungskopien. Lade ihn zuerst herunter und prüfe seinen Inhalt; führe keinen ungeprüften Remote-Code direkt über eine Pipe als Root aus.

+
curl -fsSLo /tmp/netbox-store-install.sh \
+  https://git.mrblake.cc/MrBlake/Netbox-Store/raw/branch/main/install.sh
+less /tmp/netbox-store-install.sh
+sudo bash /tmp/netbox-store-install.sh
+

Wähle für die erste Funktionsprüfung den sicheren Testmodus. Der Produktionsmodus verlangt zusätzlich die Eingabe ECHT INSTALLIEREN. Der folgende Abschnitt dokumentiert dieselben Schritte für eine manuelle Installation.

1. Plugin aus git.mrblake.cc installieren

Trage das Plugin dauerhaft in /opt/netbox/local_requirements.txt ein. Die empfohlene HTTPS-Archivvariante benötigt kein lokal installiertes Git. Für reproduzierbare Installationen solltest du main durch einen geprüften Commit-SHA ersetzen.

sudo sed -i '/^[[:space:]]*netbox-plugin-store[[:space:]]*@/d' /opt/netbox/local_requirements.txt
@@ -94,7 +101,7 @@ sudo chmod 0644 /opt/netbox/netbox/netbox/store_plugins.py /opt/netbox/local_req
 grep -qxF -- '-r /opt/netbox/local_requirements_store.txt' /opt/netbox/local_requirements.txt \
   || echo '-r /opt/netbox/local_requirements_store.txt' | sudo tee -a /opt/netbox/local_requirements.txt

Füge in configuration.py direkt nach der vorhandenen PLUGINS-Liste einmalig Folgendes ein:

-
from store_plugins import STORE_PLUGINS
+  
from netbox.store_plugins import STORE_PLUGINS
 
 PLUGINS += STORE_PLUGINS

Prüfe zunächst die Agent-Konfiguration und den Socket im sicheren Dry-Run-Modus:

diff --git a/store/tests/run.php b/store/tests/run.php index 37ab6f2..10905c9 100644 --- a/store/tests/run.php +++ b/store/tests/run.php @@ -492,6 +492,8 @@ test('public and admin templates render safely with complete artifact evidence', assertTrue(str_contains($detail, 'Wheel')); $installation = $view->render('installation', $common + ['title' => 'Installation']); assertTrue(str_contains($installation, 'https://git.mrblake.cc/MrBlake/Netbox-Store/archive/main.tar.gz')); + assertTrue(str_contains($installation, 'raw/branch/main/install.sh')); + assertTrue(str_contains($installation, 'ECHT INSTALLIEREN')); assertTrue(str_contains($installation, "sed -i '/^[[:space:]]*netbox-plugin-store")); assertTrue(str_contains($installation, 'sudo apt install -y git')); assertTrue(str_contains($installation, 'https://netbox.mrblake.cc'));