@@ -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["PY THON_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