feat: install plugins from approved source commits
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 21:37:17 +02:00
parent f36d6be511
commit 26aea40e6a
27 changed files with 414 additions and 47 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ Dieses Repository besteht aus drei getrennten Komponenten:
| --- | --- | | --- | --- |
| `store/` | Eigenständiger PHP-Store, direkt durch Apache ausgeliefert. Er synchronisiert Forgejo-/GitHub-Repositories, rendert README-Dateien und stellt den kuratierten API-Katalog bereit. | | `store/` | Eigenständiger PHP-Store, direkt durch Apache ausgeliefert. Er synchronisiert Forgejo-/GitHub-Repositories, rendert README-Dateien und stellt den kuratierten API-Katalog bereit. |
| `netbox_plugin/` | NetBox-Oberfläche für Katalog, Installation und Plugin-Lebenszyklus. Unterstützt NetBox 4.6.5 bis 4.6.8. | | `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 Wheel-Dateien prüft und die eigentlichen Änderungen am NetBox-Host ausführt. | | `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. |
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. 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.
@@ -213,7 +213,7 @@ MariaDB erhält keinen Host-Port und ist nur im internen Compose-Netz erreichbar
Der voreingestellte Forgejo-Import liest Repositories von `https://git.mrblake.cc`. GitHub ist bereits als zweiter Provider implementiert und kann im Admin-Bereich über dieselbe Quellenverwaltung hinzugefügt werden. Der voreingestellte Forgejo-Import liest Repositories von `https://git.mrblake.cc`. GitHub ist bereits als zweiter Provider implementiert und kann im Admin-Bereich über dieselbe Quellenverwaltung hinzugefügt werden.
Mit einem Token lassen sich auch Metadaten und README privater GitHub-Repositories synchronisieren. Deren private Release-Assets bleiben in API v1 bewusst nicht installierbar, weil der Host-Agent keine Provider-Zugangsdaten erhält; für automatische Installationen muss das Wheel als öffentliches Release-Asset oder auf einem anderen freigegebenen HTTPS-Host veröffentlicht werden. Mit einem Token lassen sich auch Metadaten und README privater GitHub-Repositories synchronisieren. Private Artefakte bleiben in API v1 bewusst nicht installierbar, weil der Host-Agent keine Provider-Zugangsdaten erhält. Öffentliche Projekte benötigen kein Release: Fehlt ein Wheel-Release, erzeugt der Store einen separat freizugebenden Source-Kandidaten für den exakten aktuellen Commit.
Der normale Ablauf ist: Der normale Ablauf ist:
@@ -294,7 +294,7 @@ PLUGINS_CONFIG["netbox_plugin_store"].update({
}) })
``` ```
Der Agent installiert ausschließlich freigegebene, unveränderliche Wheel-Dateien. Abhängigkeiten müssen im aktuellen MVP bereits durch den Betreiber bereitgestellt sein. Vollständige Sicherheits- und Recovery-Hinweise stehen in [`host_agent/README.md`](host_agent/README.md). Der Agent installiert ausschließlich freigegebene, unveränderliche Artefakte. Source-Archive werden commitgebunden geprüft, offline lokal in ein Wheel gebaut und als gehashte Datei unter `/opt/netbox/.netbox-store-wheels` persistiert. Abhängigkeiten und Build-Backends müssen bereits durch den Betreiber bereitgestellt sein. Vollständige Sicherheits- und Recovery-Hinweise stehen in [`host_agent/README.md`](host_agent/README.md).
## API v1 ## API v1
+4 -3
View File
@@ -6,7 +6,7 @@ decision is revalidated against the configured JSON API immediately before a lif
The daemon is **dry-run by default**. It serializes operations, persists idempotency and state in The daemon is **dry-run by default**. It serializes operations, persists idempotency and state in
SQLite, accepts one bounded JSON line per Unix-stream connection, verifies Linux peer credentials, SQLite, accepts one bounded JSON line per Unix-stream connection, verifies Linux peer credentials,
downloads only an approved immutable wheel, and invokes subprocesses only as fixed argument arrays downloads only an approved immutable wheel or commit-bound source archive, and invokes subprocesses only as fixed argument arrays
with `shell=False`. with `shell=False`.
## Install and one-time operator setup ## Install and one-time operator setup
@@ -27,7 +27,7 @@ active; this switch only permits private/reserved DNS results.
The agent owns only these two configured files: The agent owns only these two configured files:
- `paths.include_path`, which contains only `STORE_PLUGINS = [...]`; - `paths.include_path`, which contains only `STORE_PLUGINS = [...]`;
- `paths.requirements_path`, which contains the locked direct wheel references. - `paths.requirements_path`, which contains locked wheel references. Locally built wheels are cached below `/opt/netbox/.netbox-store-wheels`.
In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration: In the operator-owned NetBox `configuration.py`, add once, after the normal `PLUGINS` declaration:
@@ -112,7 +112,7 @@ value as an opaque Store marker, and the agent compares it exactly with the fres
Artifact `sha256`, in contrast, is always a lowercase 64-character SHA-256. Artifact `sha256`, in contrast, is always a lowercase 64-character SHA-256.
The plugin and release must be approved, the release immutable and compatible with the configured The plugin and release must be approved, the release immutable and compatible with the configured
NetBox version. The artifact must be a valid wheel whose filename distribution and version match NetBox version. A wheel's filename distribution and version must match
the catalog, and its byte count, digest, host, scheme and DNS addresses are checked while streaming. the catalog, and its byte count, digest, host, scheme and DNS addresses are checked while streaming.
Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test Redirects, source distributions, private/reserved DNS targets (unless explicitly enabled for a test
environment), and catalog additions outside the v1 schema fail closed. environment), and catalog additions outside the v1 schema fail closed.
@@ -151,6 +151,7 @@ netbox-store-agent status 20f4274f-d4e5-42bf-9164-967b1a774481
- `install` and `update` re-fetch plugin and release, exactly match the approval token, download and - `install` and `update` re-fetch plugin and release, exactly match the approval token, download and
validate the wheel, then use `pip --no-index --no-deps --only-binary=:all: --require-hashes`. validate the wheel, then use `pip --no-index --no-deps --only-binary=:all: --require-hashes`.
- Source archives must be tied to an exact commit. The agent rejects unsafe archive members and requires configured `unshare`/`setpriv` executables plus a dedicated build UID/GID. It builds without network access and with dropped privileges via `pip wheel --no-index --no-deps --no-build-isolation`, validates the result, and caches only that wheel for future NetBox upgrades. Reviewing source code remains a privileged trust decision because Python build backends execute code.
- New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`, - New installs remain disabled. Updating an enabled plugin runs NetBox `migrate`, `collectstatic`,
and restarts every configured service (`netbox` and `netbox-rq` in the example). and restarts every configured service (`netbox` and `netbox-rq` in the example).
- `enable` revalidates its installed release, writes the include, migrates, collects static files, - `enable` revalidates its installed release, writes the include, migrates, collects static files,
+7 -2
View File
@@ -17,7 +17,7 @@ connection_timeout_seconds = 5
worker_threads = 1 worker_threads = 1
[store] [store]
base_url = "https://store.example.invalid" base_url = "https://netbox.mrblake.cc"
plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/" plugin_endpoint_template = "/api/v1/plugins/{plugin_slug}/"
release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/" release_endpoint_template = "/api/v1/plugins/{plugin_slug}/releases/{version}/"
timeout_seconds = 10 timeout_seconds = 10
@@ -26,7 +26,7 @@ max_artifact_bytes = 268435456
allow_private_addresses = false allow_private_addresses = false
allow_http_for_testing = false allow_http_for_testing = false
# Add an artifact origin only when releases intentionally use that origin. # Add an artifact origin only when releases intentionally use that origin.
allowed_hosts = ["store.example.invalid"] allowed_hosts = ["netbox.mrblake.cc", "git.mrblake.cc", "github.com", "codeload.github.com"]
# bearer_token_file = "/etc/netbox-store-agent/store.token" # bearer_token_file = "/etc/netbox-store-agent/store.token"
# ca_file = "/etc/ssl/certs/internal-store-ca.pem" # ca_file = "/etc/ssl/certs/internal-store-ca.pem"
@@ -42,6 +42,11 @@ manage_path = "/opt/netbox/netbox/manage.py"
systemctl_path = "/usr/bin/systemctl" systemctl_path = "/usr/bin/systemctl"
services = ["netbox", "netbox-rq"] services = ["netbox", "netbox-rq"]
command_timeout_seconds = 900 command_timeout_seconds = 900
unshare_path = "/usr/bin/unshare"
setpriv_path = "/usr/bin/setpriv"
# Dedicated unprivileged account used only for reviewed source builds.
source_build_uid = 65534
source_build_gid = 65534
[policy] [policy]
netbox_version = "4.6.8" netbox_version = "4.6.8"
+73 -7
View File
@@ -8,6 +8,7 @@ import os
import socket import socket
import ssl import ssl
import stat import stat
import tarfile
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -264,6 +265,8 @@ class ReleasePlan:
artifact_sha256: str artifact_sha256: str
artifact_size: int artifact_size: int
approved_payload_sha256: str approved_payload_sha256: str
artifact_kind: str = "wheel"
artifact_filename: str = ""
def requirement(self) -> dict[str, Any]: def requirement(self) -> dict[str, Any]:
return { return {
@@ -275,6 +278,14 @@ class ReleasePlan:
"size": self.artifact_size, "size": self.artifact_size,
} }
def artifact_lock(self) -> dict[str, Any]:
return {
**self.requirement(),
"artifact_kind": self.artifact_kind,
"artifact_filename": self.artifact_filename or self.filename,
"approved_payload_sha256": self.approved_payload_sha256,
}
def _object(value: Any, context: str) -> dict[str, Any]: def _object(value: Any, context: str) -> dict[str, Any]:
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
@@ -321,6 +332,8 @@ class StoreClient:
"download_url", "download_url",
"sha256", "sha256",
"artifact_size", "artifact_size",
"artifact_kind",
"artifact_filename",
"commit_sha", "commit_sha",
"min_netbox_version", "min_netbox_version",
"max_netbox_version", "max_netbox_version",
@@ -432,12 +445,26 @@ class StoreClient:
raise CatalogError("release.commit_sha is invalid") raise CatalogError("release.commit_sha is invalid")
if data["published_at"] is not None: if data["published_at"] is not None:
_bounded_string(data["published_at"], "release.published_at", 100) _bounded_string(data["published_at"], "release.published_at", 100)
parsed_url = urlsplit(download_url) artifact_kind = _bounded_string(data["artifact_kind"], "release.artifact_kind", 32)
filename = Path(parsed_url.path).name if artifact_kind not in {"wheel", "source_archive"}:
if not filename or not filename.endswith(".whl") or len(filename) > 255: raise CatalogError("release.artifact_kind is unsupported")
raise CatalogError("approved artifact must be a wheel with a safe filename") filename = _bounded_string(data["artifact_filename"], "release.artifact_filename", 255)
if Path(filename).name != filename:
raise CatalogError("release.artifact_filename is unsafe")
if artifact_kind == "wheel" and not filename.endswith(".whl"):
raise CatalogError("wheel artifact filename is invalid")
if artifact_kind == "source_archive":
if (
not filename.endswith(".tar.gz")
or len(commit) != 40
or commit not in urlsplit(download_url).path
):
raise CatalogError("source archive must be bound to an exact commit")
# URL host/scheme/DNS are revalidated by the transport at download time. # URL host/scheme/DNS are revalidated by the transport at download time.
return ReleasePlan(plugin, version_text, download_url, filename, sha256, size, token) return ReleasePlan(
plugin, version_text, download_url, filename, sha256, size, token,
artifact_kind, filename,
)
def get_release(self, slug: str, version: str) -> ReleasePlan: def get_release(self, slug: str, version: str) -> ReleasePlan:
plugin = self.get_plugin(slug) plugin = self.get_plugin(slug)
@@ -464,8 +491,15 @@ class StoreClient:
expected_size=plan.artifact_size, expected_size=plan.artifact_size,
expected_sha256=plan.artifact_sha256, expected_sha256=plan.artifact_sha256,
) )
if plan.artifact_kind == "source_archive":
self._validate_source_archive(destination)
return destination
self.validate_wheel(plan, destination)
return destination
def validate_wheel(self, plan: ReleasePlan, destination: Path) -> None:
try: try:
distribution, wheel_version, _build, _tags = parse_wheel_filename(plan.filename) distribution, wheel_version, _build, _tags = parse_wheel_filename(destination.name)
except (InvalidVersion, ValueError) as exc: except (InvalidVersion, ValueError) as exc:
raise CatalogError("artifact filename is not a valid wheel filename") from exc raise CatalogError("artifact filename is not a valid wheel filename") from exc
if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name): if canonicalize_name(distribution) != canonicalize_name(plan.plugin.package_name):
@@ -494,4 +528,36 @@ class StoreClient:
raise CatalogError("wheel archive failed its integrity check") raise CatalogError("wheel archive failed its integrity check")
except (OSError, zipfile.BadZipFile) as exc: except (OSError, zipfile.BadZipFile) as exc:
raise CatalogError("artifact is not a valid wheel archive") from exc raise CatalogError("artifact is not a valid wheel archive") from exc
return destination
def _validate_source_archive(self, archive_path: Path) -> None:
try:
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
if not members or len(members) > 10_000:
raise CatalogError("source archive has an invalid member count")
expanded = 0
roots: set[str] = set()
has_build_file = False
for member in members:
parts = Path(member.name).parts
if (
not parts
or member.name.startswith(("/", "\\"))
or ".." in parts
or "\x00" in member.name
):
raise CatalogError("source archive contains an unsafe member")
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
raise CatalogError("source archive contains a forbidden member type")
roots.add(parts[0])
expanded += member.size
if expanded > min(
self.config.store.max_artifact_bytes * 20, 2 * 1024**3
):
raise CatalogError("source archive expands beyond the safety limit")
if len(parts) == 2 and parts[1] in {"pyproject.toml", "setup.py"}:
has_build_file = True
if len(roots) != 1 or not has_build_file:
raise CatalogError("source archive must have one root and a Python build file")
except (OSError, tarfile.TarError) as exc:
raise CatalogError("artifact is not a valid source archive") from exc
+13 -1
View File
@@ -71,6 +71,10 @@ class CommandSettings:
systemctl_path: Path systemctl_path: Path
services: tuple[str, ...] services: tuple[str, ...]
command_timeout_seconds: int command_timeout_seconds: int
unshare_path: Path | None = None
setpriv_path: Path | None = None
source_build_uid: int = 65534
source_build_gid: int = 65534
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -240,7 +244,11 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
commands_data = _table( commands_data = _table(
root, root,
"commands", "commands",
{"python_path", "manage_path", "systemctl_path", "services", "command_timeout_seconds"}, {
"python_path", "manage_path", "systemctl_path", "services",
"command_timeout_seconds", "unshare_path", "setpriv_path",
"source_build_uid", "source_build_gid",
},
) )
policy_data = _table( policy_data = _table(
root, root,
@@ -342,6 +350,10 @@ def load_config(path: str | Path, *, allow_insecure_owner: bool = False) -> Conf
systemctl_path=require_absolute_path(_string(commands_data, "systemctl_path"), "systemctl_path"), systemctl_path=require_absolute_path(_string(commands_data, "systemctl_path"), "systemctl_path"),
services=services, services=services,
command_timeout_seconds=_int(commands_data, "command_timeout_seconds", 900, 1, 7200), command_timeout_seconds=_int(commands_data, "command_timeout_seconds", 900, 1, 7200),
unshare_path=_optional_path(commands_data, "unshare_path"),
setpriv_path=_optional_path(commands_data, "setpriv_path"),
source_build_uid=_int(commands_data, "source_build_uid", 65534, 1, 2**31 - 1),
source_build_gid=_int(commands_data, "source_build_gid", 65534, 1, 2**31 - 1),
) )
versions: dict[str, str] = {} versions: dict[str, str] = {}
+89 -11
View File
@@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import os
import secrets import secrets
import shutil
import tarfile
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -56,7 +60,11 @@ class OperationProcessor:
@staticmethod @staticmethod
def _same_release(existing: ManagedPlugin, plan: ReleasePlan) -> None: def _same_release(existing: ManagedPlugin, plan: ReleasePlan) -> None:
if existing.version != plan.version or existing.requirements != (plan.requirement(),): expected = plan.artifact_lock() if existing.artifact_lock else plan.requirement()
current_lock = existing.artifact_lock or (
existing.requirements[0] if existing.requirements else {}
)
if existing.version != plan.version or current_lock != expected:
raise PolicyError( raise PolicyError(
"managed artifact lock differs from the current immutable Store release" "managed artifact lock differs from the current immutable Store release"
) )
@@ -105,6 +113,71 @@ class OperationProcessor:
], ],
) )
def _build_source_wheel(
self, operation_id: str, directory: Path, plan: ReleasePlan, archive: Path
) -> Path:
if (
os.name != "posix"
or self.config.commands.unshare_path is None
or self.config.commands.setpriv_path is None
):
raise PolicyError("source builds require configured Linux unshare and setpriv executables")
source_dir = directory / "source"
wheel_dir = directory / "wheel"
source_dir.mkdir(mode=0o700)
wheel_dir.mkdir(mode=0o700)
with tarfile.open(archive, "r:gz") as bundle:
bundle.extractall(source_dir, filter="data")
roots = list(source_dir.iterdir())
if len(roots) != 1 or not roots[0].is_dir():
raise PolicyError("source archive extraction produced an invalid root")
uid = self.config.commands.source_build_uid
gid = self.config.commands.source_build_gid
for path in [directory, source_dir, wheel_dir, *source_dir.rglob("*")]:
os.chown(path, uid, gid, follow_symlinks=False)
if path.is_dir():
os.chmod(path, 0o700)
self._run(
operation_id,
"source_build",
[
str(self.config.commands.unshare_path), "--net", "--",
str(self.config.commands.setpriv_path), f"--reuid={uid}", f"--regid={gid}",
"--clear-groups", "--no-new-privs", "--",
str(self.config.commands.python_path), "-m", "pip", "wheel",
"--no-input", "--disable-pip-version-check", "--no-index", "--no-deps",
"--no-build-isolation", "--wheel-dir", str(wheel_dir), str(roots[0]),
],
)
wheels = list(wheel_dir.glob("*.whl"))
if len(wheels) != 1:
raise PolicyError("source build must produce exactly one wheel")
self.store.validate_wheel(plan, wheels[0])
return wheels[0]
def _cache_wheel(self, plan: ReleasePlan, wheel: Path) -> tuple[Path, dict[str, object]]:
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
cache = (
self.config.paths.allowed_root
/ ".netbox-store-wheels"
/ plan.plugin.slug
/ plan.approved_payload_sha256
)
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
destination = cache / wheel.name
if destination.exists() and destination.is_symlink():
raise PolicyError("wheel cache destination may not be a symlink")
temporary = cache / (wheel.name + ".tmp")
shutil.copyfile(wheel, temporary)
os.chmod(temporary, 0o600)
os.replace(temporary, destination)
requirement = {
"package_name": plan.plugin.package_name, "version": plan.version,
"download_url": destination.as_uri(), "filename": destination.name,
"sha256": digest, "size": destination.stat().st_size,
}
return destination, requirement
def _pip_uninstall(self, operation_id: str, package_name: str) -> None: def _pip_uninstall(self, operation_id: str, package_name: str) -> None:
self._run( self._run(
operation_id, operation_id,
@@ -184,23 +257,26 @@ class OperationProcessor:
if existing is not None: if existing is not None:
self._same_identity(existing, plan.plugin) self._same_identity(existing, plan.plugin)
enabled = existing.enabled if existing else False enabled = existing.enabled if existing else False
managed = ManagedPlugin(
slug=request.plugin_slug,
package_name=plan.plugin.package_name,
import_name=plan.plugin.import_name,
version=plan.version,
enabled=enabled,
requirements=(plan.requirement(),),
)
temp_root = self.config.paths.temp_dir temp_root = self.config.paths.temp_dir
if temp_root.is_symlink(): if temp_root.is_symlink():
raise PolicyError("temporary directory may not be a 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=0o700)
with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary: with tempfile.TemporaryDirectory(prefix="operation-", dir=temp_root) as temporary:
self._event(operation_id, "artifact", "Downloading and verifying approved wheel") self._event(operation_id, "artifact", "Downloading and verifying approved artifact")
wheel = self.store.download_release(plan, Path(temporary)) artifact = self.store.download_release(plan, Path(temporary))
if self.config.agent.dry_run: if self.config.agent.dry_run:
return self._dry_result(request, version=plan.version, artifact_verified=True), False return self._dry_result(request, version=plan.version, artifact_verified=True), False
wheel = (
self._build_source_wheel(operation_id, Path(temporary), plan, artifact)
if plan.artifact_kind == "source_archive"
else artifact
)
wheel, requirement = self._cache_wheel(plan, wheel)
managed = ManagedPlugin(
slug=request.plugin_slug, package_name=plan.plugin.package_name,
import_name=plan.plugin.import_name, version=plan.version, enabled=enabled,
requirements=(requirement,), artifact_lock=plan.artifact_lock(),
)
self._mark_mutated(operation_id) self._mark_mutated(operation_id)
host_mutated = True host_mutated = True
self._pip_install(operation_id, Path(temporary), managed, wheel) self._pip_install(operation_id, Path(temporary), managed, wheel)
@@ -239,6 +315,7 @@ class OperationProcessor:
existing.version, existing.version,
True, True,
existing.requirements, existing.requirements,
existing.artifact_lock,
) )
if self.config.agent.dry_run: if self.config.agent.dry_run:
return self._dry_result(request, version=existing.version, enabled=True), False return self._dry_result(request, version=existing.version, enabled=True), False
@@ -260,6 +337,7 @@ class OperationProcessor:
existing.version, existing.version,
False, False,
existing.requirements, existing.requirements,
existing.artifact_lock,
) )
if self.config.agent.dry_run: if self.config.agent.dry_run:
return self._dry_result(request, version=existing.version, enabled=False), False return self._dry_result(request, version=existing.version, enabled=False), False
+14 -2
View File
@@ -31,6 +31,7 @@ class ManagedPlugin:
version: str version: str
enabled: bool enabled: bool
requirements: tuple[dict[str, Any], ...] requirements: tuple[dict[str, Any], ...]
artifact_lock: dict[str, Any] | None = None
class Journal: class Journal:
@@ -111,10 +112,17 @@ class Journal:
version TEXT NOT NULL, version TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)), enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
requirements_json TEXT NOT NULL, requirements_json TEXT NOT NULL,
artifact_lock_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
""" """
) )
columns = {row[1] for row in connection.execute("PRAGMA table_info(managed_plugins)")}
if "artifact_lock_json" not in columns:
connection.execute(
"ALTER TABLE managed_plugins ADD COLUMN artifact_lock_json "
"TEXT NOT NULL DEFAULT '{}'"
)
@staticmethod @staticmethod
def _operation_dict(row: sqlite3.Row, events: list[dict[str, Any]] | None = None) -> dict[str, Any]: def _operation_dict(row: sqlite3.Row, events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
@@ -306,6 +314,7 @@ class Journal:
version=row["version"], version=row["version"],
enabled=bool(row["enabled"]), enabled=bool(row["enabled"]),
requirements=tuple(requirements), requirements=tuple(requirements),
artifact_lock=json.loads(row["artifact_lock_json"]),
) )
def get_managed_plugin(self, slug: str) -> ManagedPlugin | None: def get_managed_plugin(self, slug: str) -> ManagedPlugin | None:
@@ -322,18 +331,20 @@ class Journal:
def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None: def upsert_managed_plugin(self, plugin: ManagedPlugin) -> None:
requirements_json = canonical_json(list(plugin.requirements)) requirements_json = canonical_json(list(plugin.requirements))
artifact_lock_json = canonical_json(plugin.artifact_lock)
with self._connect() as connection: with self._connect() as connection:
connection.execute( connection.execute(
""" """
INSERT INTO managed_plugins( INSERT INTO managed_plugins(
slug, package_name, import_name, version, enabled, requirements_json, updated_at slug, package_name, import_name, version, enabled, requirements_json, artifact_lock_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(slug) DO UPDATE SET
package_name=excluded.package_name, package_name=excluded.package_name,
import_name=excluded.import_name, import_name=excluded.import_name,
version=excluded.version, version=excluded.version,
enabled=excluded.enabled, enabled=excluded.enabled,
requirements_json=excluded.requirements_json, requirements_json=excluded.requirements_json,
artifact_lock_json=excluded.artifact_lock_json,
updated_at=excluded.updated_at updated_at=excluded.updated_at
""", """,
( (
@@ -343,6 +354,7 @@ class Journal:
plugin.version, plugin.version,
int(plugin.enabled), int(plugin.enabled),
requirements_json, requirements_json,
artifact_lock_json,
utc_now(), utc_now(),
), ),
) )
@@ -144,6 +144,7 @@ class ManagedFiles:
def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes: def render_requirements(self, plugins: Iterable[ManagedPlugin]) -> bytes:
requirements: dict[str, dict[str, object]] = {} requirements: dict[str, dict[str, object]] = {}
schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"} schemes = {"https", "http"} if self.config.store.allow_http_for_testing else {"https"}
schemes.add("file")
for plugin in plugins: for plugin in plugins:
for raw in plugin.requirements: for raw in plugin.requirements:
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -166,10 +167,17 @@ class ManagedFiles:
if not isinstance(url, str) or any(char in url for char in "\r\n\t "): if not isinstance(url, str) or any(char in url for char in "\r\n\t "):
raise ValidationError("managed requirement URL is invalid") raise ValidationError("managed requirement URL is invalid")
parsed = urlsplit(url) parsed = urlsplit(url)
remote_invalid = parsed.scheme != "file" and (
not parsed.hostname or parsed.hostname.lower() not in self.config.store.allowed_hosts
)
local_path = Path(parsed.path.lstrip("/") if os.name == "nt" else parsed.path)
local_invalid = parsed.scheme == "file" and not is_relative_to(
local_path.resolve(strict=False), self.config.paths.allowed_root
)
if ( if (
parsed.scheme not in schemes parsed.scheme not in schemes
or not parsed.hostname or remote_invalid
or parsed.hostname.lower() not in self.config.store.allowed_hosts or local_invalid
or parsed.username or parsed.username
or parsed.password or parsed.password
or parsed.fragment or parsed.fragment
+2
View File
@@ -88,6 +88,8 @@ def release_json(version: str = "1.2.3", **overrides: Any) -> dict[str, Any]:
"download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl", "download_url": f"http://artifacts.test/demo_plugin-{version}-py3-none-any.whl",
"sha256": "a" * 64, "sha256": "a" * 64,
"artifact_size": 100, "artifact_size": 100,
"artifact_kind": "wheel",
"artifact_filename": f"demo_plugin-{version}-py3-none-any.whl",
"commit_sha": "", "commit_sha": "",
"min_netbox_version": "4.6.5", "min_netbox_version": "4.6.5",
"max_netbox_version": "4.6.8", "max_netbox_version": "4.6.8",
+32
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import io
import tarfile
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -48,6 +50,36 @@ class CatalogTests(unittest.TestCase):
plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3") plan = StoreClient(self.config, transport).get_release("demo-plugin", "1.2.3")
self.assertEqual(plan.version, "1.2.3") self.assertEqual(plan.version, "1.2.3")
def test_commit_bound_source_archive_is_verified(self) -> None:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
content = b"[build-system]\nrequires = []\n"
info = tarfile.TarInfo("demo/pyproject.toml")
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
artifact = buffer.getvalue()
commit = "d" * 40
release = release_json(
download_url=f"http://artifacts.test/demo/archive/{commit}.tar.gz",
artifact_kind="source_archive",
artifact_filename="demo-plugin-1.2.3-source.tar.gz",
commit_sha=commit,
sha256=hashlib.sha256(artifact).hexdigest(),
artifact_size=len(artifact),
)
transport = FakeTransport(
{
"http://store.test/api/v1/plugins/demo-plugin": plugin_json(),
"http://store.test/api/v1/plugins/demo-plugin/releases/1.2.3": release,
},
artifact,
)
client = StoreClient(self.config, transport)
plan = client.get_release("demo-plugin", "1.2.3")
directory = self.root / "source-operation"
directory.mkdir()
self.assertEqual(client.download_release(plan, directory).read_bytes(), artifact)
def test_unapproved_or_mutable_release_rejected(self) -> None: def test_unapproved_or_mutable_release_rejected(self) -> None:
for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}): for change in ({"approved": False}, {"immutable": False}, {"status": "pending"}):
with self.subTest(change=change): with self.subTest(change=change):
@@ -77,6 +77,8 @@ class Release:
approved: bool approved: bool
immutable: bool immutable: bool
approved_payload_sha256: str approved_payload_sha256: str
artifact_kind: str = "wheel"
artifact_filename: str = ""
@classmethod @classmethod
def from_mapping(cls, value: dict[str, Any]) -> "Release": def from_mapping(cls, value: dict[str, Any]) -> "Release":
@@ -104,6 +106,8 @@ class Release:
approved=value.get("approved") is True or value.get("status") == "approved", approved=value.get("approved") is True or value.get("status") == "approved",
immutable=value.get("immutable") is True, immutable=value.get("immutable") is True,
approved_payload_sha256=approved_payload_sha256, approved_payload_sha256=approved_payload_sha256,
artifact_kind=str(value.get("artifact_kind") or "wheel").strip(),
artifact_filename=str(value.get("artifact_filename") or "").strip(),
) )
def supports(self, netbox_version: str, plugin: "CatalogPlugin") -> bool: def supports(self, netbox_version: str, plugin: "CatalogPlugin") -> bool:
@@ -235,6 +235,8 @@ class LifecycleService:
raise LifecycleError("Approved release has no artifact URL.") raise LifecycleError("Approved release has no artifact URL.")
if not release.sha256: if not release.sha256:
raise LifecycleError("Approved release has no artifact SHA-256. Commit hashes are not accepted.") raise LifecycleError("Approved release has no artifact SHA-256. Commit hashes are not accepted.")
if release.artifact_kind not in {"wheel", "source_archive"}:
raise LifecycleError("Approved release has an unsupported artifact type.")
@staticmethod @staticmethod
def _assert_state(action: str, installed: str, enabled: bool, runtime_active: bool) -> None: def _assert_state(action: str, installed: str, enabled: bool, runtime_active: bool) -> None:
@@ -287,6 +289,8 @@ class LifecycleService:
return suffix if re.fullmatch(r"\.[A-Za-z0-9.]{1,16}", suffix) else ".artifact" return suffix if re.fullmatch(r"\.[A-Za-z0-9.]{1,16}", suffix) else ".artifact"
def _download(self, release: Release, directory: Path) -> Path: def _download(self, release: Release, directory: Path) -> Path:
if release.artifact_kind == "source_archive":
raise LifecycleError("Source archives require execution_mode='agent' for a verified local wheel build.")
artifact = directory / f"artifact{self._artifact_suffix(release)}" artifact = directory / f"artifact{self._artifact_suffix(release)}"
self.client.download_artifact( self.client.download_artifact(
release.download_url, release.download_url,
+12
View File
@@ -232,6 +232,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--blue); box-shado
.sync-errors ul { margin: 7px 0 0; padding-left: 18px; } .sync-errors ul { margin: 7px 0 0; padding-left: 18px; }
.sync-errors li { display: list-item; padding: 3px 0; border: 0; overflow-wrap: anywhere; } .sync-errors li { display: list-item; padding: 3px 0; border: 0; overflow-wrap: anywhere; }
.install-hero { padding: 64px 0 48px; color: #fff; background: #102a43; }
.install-hero h1 { margin: 6px 0 12px; font-size: clamp(2rem, 5vw, 3.5rem); letter-spacing: -.04em; }
.install-hero p:last-child { color: #c6d8e5; }
.prose-shell { max-width: 880px; }
.install-guide { padding-block: 42px 80px; }
.install-guide h2 { margin: 38px 0 10px; }
.install-guide p { color: var(--ink-soft); line-height: 1.7; }
.install-guide pre { padding: 19px; overflow-x: auto; border: 1px solid #214761; border-radius: 11px; color: #eaf4fb; background: #102a43; line-height: 1.55; }
.install-guide code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.notice { padding: 16px 18px; border-left: 4px solid var(--blue); border-radius: 8px; background: #edf5ff; line-height: 1.55; }
.notice.warning { margin-top: 28px; border-left-color: var(--amber); background: var(--amber-bg); }
@media (max-width: 980px) { @media (max-width: 980px) {
.hero-grid { grid-template-columns: 1fr; gap: 35px; } .hero-grid { grid-template-columns: 1fr; gap: 35px; }
.hero-stat { width: min(350px, 100%); } .hero-stat { width: min(350px, 100%); }
+25 -3
View File
@@ -17,6 +17,7 @@ final class Approval
'commitSha' => strtolower((string) ($release['commitSha'] ?? '')), 'commitSha' => strtolower((string) ($release['commitSha'] ?? '')),
'downloadUrl' => (string) ($release['downloadUrl'] ?? ''), 'downloadUrl' => (string) ($release['downloadUrl'] ?? ''),
'artifactKind' => (string) ($release['artifactKind'] ?? ''), 'artifactKind' => (string) ($release['artifactKind'] ?? ''),
'artifactFilename' => (string) ($release['artifactFilename'] ?? ''),
'importName' => (string) ($plugin['importName'] ?? ''), 'importName' => (string) ($plugin['importName'] ?? ''),
'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')), 'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
'minNetboxVersion' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')), 'minNetboxVersion' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
@@ -95,7 +96,10 @@ final class Approval
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) || isset($url['user']) || isset($url['pass'])) { if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) || isset($url['user']) || isset($url['pass'])) {
$errors[] = 'Download-URL muss credential-freies HTTPS sein.'; $errors[] = 'Download-URL muss credential-freies HTTPS sein.';
} else { } else {
$errors = array_merge($errors, self::wheelErrors($plugin, $release, (string) ($url['path'] ?? ''))); $kind = (string) ($release['artifactKind'] ?? '');
$errors = array_merge($errors, $kind === 'source_archive'
? self::sourceErrors($release, (string) ($url['path'] ?? ''))
: self::wheelErrors($plugin, $release, (string) ($url['path'] ?? '')));
} }
$commit = (string) ($release['commitSha'] ?? ''); $commit = (string) ($release['commitSha'] ?? '');
if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) { if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
@@ -109,8 +113,8 @@ final class Approval
if (!self::immutable($release)) { if (!self::immutable($release)) {
$errors[] = 'Artefakt-SHA oder Größe fehlt.'; $errors[] = 'Artefakt-SHA oder Größe fehlt.';
} }
if (($release['artifactKind'] ?? '') !== 'wheel') { if (!in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true)) {
$errors[] = 'Im API-v1-Katalog sind ausschließlich Wheel-Artefakte freigabefähig.'; $errors[] = 'Nur Wheel- oder commitgebundene Source-Artefakte sind freigabefähig.';
} }
if (!empty($release['draft']) || !empty($release['withdrawn'])) { if (!empty($release['draft']) || !empty($release['withdrawn'])) {
$errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.'; $errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.';
@@ -259,4 +263,22 @@ final class Approval
} }
return $errors; return $errors;
} }
/** @param array<string,mixed> $release @return list<string> */
private static function sourceErrors(array $release, string $path): array
{
$commit = (string) ($release['commitSha'] ?? '');
$filename = (string) ($release['artifactFilename'] ?? '');
$errors = [];
if (preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
$errors[] = 'Source-Artefakte benoetigen einen exakten 40-stelligen Commit-SHA.';
}
if ($filename === '' || strlen($filename) > 255 || preg_match('/^[A-Za-z0-9._+-]+\.tar\.gz$/', $filename) !== 1) {
$errors[] = 'Source-Artefaktname ist ungueltig.';
}
if ($commit !== '' && !str_contains(rawurldecode($path), $commit)) {
$errors[] = 'Source-Download-URL ist nicht an den freigegebenen Commit gebunden.';
}
return $errors;
}
} }
+2
View File
@@ -94,6 +94,8 @@ final class Catalog
'download_url' => (string) $release['downloadUrl'], 'download_url' => (string) $release['downloadUrl'],
'sha256' => strtolower((string) $release['sha256']), 'sha256' => strtolower((string) $release['sha256']),
'artifact_size' => (int) $release['artifactSize'], 'artifact_size' => (int) $release['artifactSize'],
'artifact_kind' => (string) $release['artifactKind'],
'artifact_filename' => (string) $release['artifactFilename'],
'commit_sha' => (string) ($release['commitSha'] ?? ''), 'commit_sha' => (string) ($release['commitSha'] ?? ''),
'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')), 'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')), 'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
+3
View File
@@ -60,6 +60,9 @@ final class Application
if ($request->method === 'GET' && $request->path === '/') { if ($request->method === 'GET' && $request->path === '/') {
return $this->home($request); return $this->home($request);
} }
if ($request->method === 'GET' && $request->path === '/installation') {
return $this->viewResponse('installation', ['title' => 'NetBox Store installieren'], 200, $request);
}
if ($request->method === 'GET' && preg_match('#^/plugins/([^/]+)$#', $request->path, $match)) { if ($request->method === 'GET' && preg_match('#^/plugins/([^/]+)$#', $request->path, $match)) {
return $this->plugin($request, $match[1]); return $this->plugin($request, $match[1]);
} }
+11
View File
@@ -49,6 +49,7 @@ final class ForgejoAdapter extends AbstractAdapter
'archived' => (bool) ($item['archived'] ?? false), 'archived' => (bool) ($item['archived'] ?? false),
'fork' => (bool) ($item['fork'] ?? false), 'fork' => (bool) ($item['fork'] ?? false),
'empty' => (bool) ($item['empty'] ?? false), 'empty' => (bool) ($item['empty'] ?? false),
'private' => (bool) ($item['private'] ?? false),
]; ];
$this->validateRepository($repository); $this->validateRepository($repository);
return $repository; return $repository;
@@ -135,6 +136,16 @@ final class ForgejoAdapter extends AbstractAdapter
return $releases; return $releases;
} }
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
{
if (!empty($repository['private'])) {
return null;
}
$this->validateRepository($repository);
$this->assertCommit($commitSha);
return rtrim((string) $repository['htmlUrl'], '/') . '/archive/' . $commitSha . '.tar.gz';
}
/** @return list<array<string,mixed>>|null */ /** @return list<array<string,mixed>>|null */
private function pagedRepositories(string $endpoint): ?array private function pagedRepositories(string $endpoint): ?array
{ {
+10
View File
@@ -138,6 +138,16 @@ final class GitHubAdapter extends AbstractAdapter
return $releases; return $releases;
} }
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
{
if (!empty($repository['private'])) {
return null;
}
$this->validateRepository($repository);
$this->assertCommit($commitSha);
return 'https://codeload.github.com/' . $repository['fullName'] . '/tar.gz/' . $commitSha;
}
/** @return list<array<string,mixed>>|null */ /** @return list<array<string,mixed>>|null */
private function paged(string $endpoint): ?array private function paged(string $endpoint): ?array
{ {
+3
View File
@@ -24,6 +24,9 @@ interface SourceAdapter
/** @param array<string,mixed> $repository @return list<array<string,mixed>> */ /** @param array<string,mixed> $repository @return list<array<string,mixed>> */
public function listReleases(array $repository): array; public function listReleases(array $repository): array;
/** @param array<string,mixed> $repository */
public function sourceArchiveUrl(array $repository, string $commitSha): ?string;
/** @return array{sha256:string,artifactSize:int} */ /** @return array{sha256:string,artifactSize:int} */
public function hashArtifact(string $url, string $expectedSha256 = ''): array; public function hashArtifact(string $url, string $expectedSha256 = ''): array;
} }
+27 -3
View File
@@ -19,7 +19,7 @@ use Closure;
final class SyncService final class SyncService
{ {
private const PLUGIN_SECURITY = ['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion']; private const PLUGIN_SECURITY = ['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'];
private const RELEASE_SECURITY = ['version', 'downloadUrl', 'sha256', 'artifactSize', 'artifactKind', 'commitSha', 'minNetboxVersion', 'maxNetboxVersion', 'withdrawn']; private const RELEASE_SECURITY = ['version', 'downloadUrl', 'sha256', 'artifactSize', 'artifactKind', 'artifactFilename', 'commitSha', 'minNetboxVersion', 'maxNetboxVersion', 'withdrawn'];
public function __construct( public function __construct(
private readonly StoreRepository $repository, private readonly StoreRepository $repository,
@@ -199,7 +199,7 @@ final class SyncService
// GitHub's browser_download_url is deliberately public-only. // GitHub's browser_download_url is deliberately public-only.
// Provider credentials stay on the API origin and the v1 // Provider credentials stay on the API origin and the v1
// Host-Agent has no credentials for private asset downloads. // Host-Agent has no credentials for private asset downloads.
if (($source['provider'] ?? '') === 'github' && !empty($repository['private'])) { if (!empty($repository['private'])) {
$this->withdrawMissingReleases((string) $plugin['id'], []); $this->withdrawMissingReleases((string) $plugin['id'], []);
$run['errors'][] = [ $run['errors'][] = [
'repository' => $repository['fullName'], 'repository' => $repository['fullName'],
@@ -209,6 +209,29 @@ final class SyncService
} }
$releaseInfos = $adapter->listReleases($result['repository']); $releaseInfos = $adapter->listReleases($result['repository']);
if ($releaseInfos === []) {
$sourceUrl = $adapter->sourceArchiveUrl($result['repository'], (string) $result['repository']['commitSha']);
$version = Support::safeVersion($plugin['latestVersion'] ?? '');
$commit = (string) $result['repository']['commitSha'];
if ($sourceUrl !== null && $version !== '' && preg_match('/^[a-f0-9]{40}$/', $commit)) {
$package = strtolower((string) preg_replace('/[-_.]+/', '-', (string) $plugin['packageName']));
$releaseInfos[] = [
'externalId' => 'source:' . $commit,
'version' => $version,
'title' => 'Source ' . substr($commit, 0, 12),
'releaseUrl' => rtrim((string) $repository['htmlUrl'], '/') . '/commit/' . $commit,
'downloadUrl' => $sourceUrl,
'expectedSha256' => '',
'commitSha' => $commit,
'artifactKind' => 'source_archive',
'artifactFilename' => $package . '-' . $version . '-source.tar.gz',
'prerelease' => false,
'draft' => false,
'changelog' => 'Automatisch aus dem freigegebenen Repository-Commit erzeugter Source-Build.',
'publishedAt' => null,
];
}
}
$releaseInfosSeen += count($releaseInfos); $releaseInfosSeen += count($releaseInfos);
$this->http->ensureReleasesCounted($releaseInfosSeen); $this->http->ensureReleasesCounted($releaseInfosSeen);
foreach (array_filter($releaseInfos) as $releaseInfo) { foreach (array_filter($releaseInfos) as $releaseInfo) {
@@ -418,7 +441,8 @@ final class SyncService
'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl, 'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl,
'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'], 'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'],
'commitSha' => preg_match('/^[a-f0-9]{40}$/', $releaseInfo['commitSha'] ?? '') ? $releaseInfo['commitSha'] : '', 'commitSha' => preg_match('/^[a-f0-9]{40}$/', $releaseInfo['commitSha'] ?? '') ? $releaseInfo['commitSha'] : '',
'artifactKind' => str_ends_with(strtolower(parse_url($downloadUrl, PHP_URL_PATH) ?: ''), '.whl') ? 'wheel' : 'invalid', 'artifactKind' => (string) ($releaseInfo['artifactKind'] ?? (str_ends_with(strtolower(parse_url($downloadUrl, PHP_URL_PATH) ?: ''), '.whl') ? 'wheel' : 'invalid')),
'artifactFilename' => (string) ($releaseInfo['artifactFilename'] ?? basename((string) (parse_url($downloadUrl, PHP_URL_PATH) ?: ''))),
'prerelease' => (bool) ($releaseInfo['prerelease'] ?? false), 'draft' => (bool) ($releaseInfo['draft'] ?? false), 'prerelease' => (bool) ($releaseInfo['prerelease'] ?? false), 'draft' => (bool) ($releaseInfo['draft'] ?? false),
'withdrawn' => false, 'withdrawn' => false,
'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null, 'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null,
+2 -2
View File
@@ -68,7 +68,7 @@ include dirname(__DIR__) . '/partials/head.php';
<?php foreach (($sources ?? []) as $source): ?> <?php foreach (($sources ?? []) as $source): ?>
<tr> <tr>
<td><strong><?= $e($source['name']) ?></strong><small><?= $e($source['baseUrl']) ?></small></td> <td><strong><?= $e($source['name']) ?></strong><small><?= $e($source['baseUrl']) ?></small></td>
<td><?= $e($source['provider']) ?> · <?= $e($source['owner']) ?><small>Öffentliche Wheel-Releases</small></td> <td><?= $e($source['provider']) ?> · <?= $e($source['owner']) ?><small>Wheels oder commitgebundener Source-Fallback</small></td>
<td><span class="status-pill <?= $e($statusClass($source['status'])) ?>"><?= $e($statusLabel($source['status'])) ?></span></td> <td><span class="status-pill <?= $e($statusClass($source['status'])) ?>"><?= $e($statusLabel($source['status'])) ?></span></td>
<td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td> <td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td>
<td><div class="action-row"> <td><div class="action-row">
@@ -126,7 +126,7 @@ include dirname(__DIR__) . '/partials/head.php';
<table class="admin-table release-admin-table"> <table class="admin-table release-admin-table">
<thead><tr><th>Plugin / Version</th><th>Artefakt</th><th>Integrität</th><th>Status</th><th>Aktionen</th></tr></thead> <thead><tr><th>Plugin / Version</th><th>Artefakt</th><th>Integrität</th><th>Status</th><th>Aktionen</th></tr></thead>
<tbody> <tbody>
<?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine Release-Artefakte gefunden. Veröffentliche ein Wheel als Forgejo-Release-Asset.</td></tr><?php endif; ?> <?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine installierbaren Artefakte gefunden. Prüfe Version und Build-Metadaten des Repositorys.</td></tr><?php endif; ?>
<?php foreach (($releases ?? []) as $release): ?> <?php foreach (($releases ?? []) as $release): ?>
<?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?> <?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?>
<tr> <tr>
+2 -2
View File
@@ -59,12 +59,12 @@
<?php else: ?> <?php else: ?>
<div class="plugin-grid"> <div class="plugin-grid">
<?php foreach ($plugins as $plugin): ?> <?php foreach ($plugins as $plugin): ?>
<?php $release = $plugin['latestRelease'] ?? null; $isWheel = is_array($release) && ($release['artifactKind'] ?? '') === 'wheel'; ?> <?php $release = $plugin['latestRelease'] ?? null; $isWheel = is_array($release) && in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true); ?>
<article class="plugin-card"> <article class="plugin-card">
<div class="card-topline"> <div class="card-topline">
<span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span> <span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span>
<?php if ($isWheel): ?> <?php if ($isWheel): ?>
<span class="status-pill success">Wheel geprüft</span> <span class="status-pill success"><?= ($release['artifactKind'] ?? '') === 'source_archive' ? 'Source freigegeben' : 'Wheel geprüft' ?></span>
<?php elseif ($release): ?> <?php elseif ($release): ?>
<span class="status-pill warning">Kein installierbares Release</span> <span class="status-pill warning">Kein installierbares Release</span>
<?php else: ?> <?php else: ?>
+42
View File
@@ -0,0 +1,42 @@
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
<section class="install-hero">
<div class="shell prose-shell">
<p class="eyebrow">NetBox 4.6.54.6.8</p>
<h1>NetBox Store Plugin installieren</h1>
<p>Das Plugin verbindet deine NetBox mit <code>https://netbox.mrblake.cc</code>. Docker ist nicht erforderlich.</p>
</div>
</section>
<section class="shell prose-shell install-guide">
<aside class="notice"><strong>Vorher sichern:</strong> Erstelle ein Backup von NetBox und der Datenbank. Führe die Befehle auf dem NetBox-Host aus.</aside>
<h2>1. Plugin aus git.mrblake.cc installieren</h2>
<p>Trage das Plugin dauerhaft in <code>/opt/netbox/local_requirements.txt</code> ein. Für reproduzierbare Installationen solltest du <code>main</code> durch einen geprüften Commit-SHA ersetzen.</p>
<pre><code>sudo sh -c 'printf "%s\n" "netbox-plugin-store @ git+https://git.mrblake.cc/MrBlake/Netbox-Store.git@main#subdirectory=netbox_plugin" &gt;&gt; /opt/netbox/local_requirements.txt'
sudo /opt/netbox/upgrade.sh</code></pre>
<h2>2. Plugin in NetBox aktivieren</h2>
<p>Ergänze die NetBox-Konfiguration:</p>
<pre><code>PLUGINS = [
"netbox_plugin_store",
]
PLUGINS_CONFIG = {
"netbox_plugin_store": {
"store_url": "https://netbox.mrblake.cc",
"allowed_store_urls": ["https://netbox.mrblake.cc"],
"allowed_artifact_urls": [
"https://git.mrblake.cc",
"https://github.com",
"https://codeload.github.com",
],
"execution_mode": "dry_run",
}
}</code></pre>
<p>Führe danach Migrationen und statische Dateien aus und starte NetBox neu:</p>
<pre><code>sudo /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py migrate
sudo /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq</code></pre>
<h2>3. Sicheren Host-Agent einrichten</h2>
<p><code>dry_run</code> verändert das System nicht. Für Installieren, Aktualisieren, Aktivieren und Entfernen wird der mitgelieferte Linux Host-Agent benötigt. Installiere ihn aus <code>host_agent/</code>, prüfe <code>/etc/netbox-store-agent/agent.toml</code> und stelle anschließend <code>execution_mode</code> auf <code>agent</code>.</p>
<p>Source-Kandidaten werden nur nach Admin-Freigabe verarbeitet. Der Agent prüft Commitbindung, Größe und SHA-256, baut daraus lokal ein Wheel und installiert nicht direkt aus einem beweglichen Branch.</p>
<aside class="notice warning"><strong>Wichtig:</strong> Source-Builds führen den Build-Code des freigegebenen Repository-Commits aus. Gib nur vertrauenswürdige Kandidaten frei.</aside>
</section>
<?php include __DIR__ . '/partials/footer.php'; ?>
+1
View File
@@ -7,6 +7,7 @@
<p>Freigegebene Metadaten, reproduzierbar geprüfte Artefakte.</p> <p>Freigegebene Metadaten, reproduzierbar geprüfte Artefakte.</p>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="/installation">Installation</a>
<a href="/api/v1/plugins/">Catalog API v1</a> <a href="/api/v1/plugins/">Catalog API v1</a>
<a href="/healthz">Systemstatus</a> <a href="/healthz">Systemstatus</a>
</div> </div>
+1
View File
@@ -21,6 +21,7 @@
</a> </a>
<nav class="main-nav" aria-label="Hauptnavigation"> <nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a> <a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a>
<a class="<?= ($currentPath ?? '') === '/installation' ? 'active' : '' ?>" href="/installation">Installation</a>
<a href="/api/v1/plugins/">API</a> <a href="/api/v1/plugins/">API</a>
<?php if (!empty($adminEnabled)): ?> <?php if (!empty($adminEnabled)): ?>
<a class="<?= str_starts_with((string) ($currentPath ?? ''), '/admin') ? 'active' : '' ?>" href="/admin">Admin</a> <a class="<?= str_starts_with((string) ($currentPath ?? ''), '/admin') ? 'active' : '' ?>" href="/admin">Admin</a>
+4 -4
View File
@@ -1,5 +1,5 @@
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?> <?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
<?php $wheelReleases = array_values(array_filter($releases ?? [], static fn (array $release): bool => ($release['artifactKind'] ?? '') === 'wheel')); ?> <?php $installableReleases = array_values(array_filter($releases ?? [], static fn (array $release): bool => in_array(($release['artifactKind'] ?? ''), ['wheel', 'source_archive'], true))); ?>
<section class="detail-hero"> <section class="detail-hero">
<div class="shell"> <div class="shell">
<a class="back-link" href="/"> Alle Plugins</a> <a class="back-link" href="/"> Alle Plugins</a>
@@ -42,10 +42,10 @@
<section class="side-card releases-card"> <section class="side-card releases-card">
<div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div> <div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div>
<?php if ($wheelReleases === []): ?> <?php if ($installableReleases === []): ?>
<div class="release-warning"> <div class="release-warning">
<strong>Kein installierbares Release</strong> <strong>Kein installierbares Release</strong>
<p>Für die automatische Installation durch den Host-Agenten fehlt ein freigegebenes <code>.whl</code>-Artefakt.</p> <p>Für die automatische Installation fehlt ein freigegebenes Wheel oder commitgebundenes Source-Artefakt.</p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (($releases ?? []) === []): ?> <?php if (($releases ?? []) === []): ?>
@@ -56,7 +56,7 @@
<li> <li>
<div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div> <div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div>
<div class="release-tags"> <div class="release-tags">
<span class="status-pill success">Wheel</span> <span class="status-pill success"><?= ($release['artifactKind'] ?? '') === 'source_archive' ? 'Source-Build' : 'Wheel' ?></span>
<span><?= $e($formatBytes($release['artifactSize'])) ?></span> <span><?= $e($formatBytes($release['artifactSize'])) ?></span>
</div> </div>
<code class="hash" title="SHA-256"><?= $e(substr($release['sha256'], 0, 16)) ?>…</code> <code class="hash" title="SHA-256"><?= $e(substr($release['sha256'], 0, 16)) ?>…</code>
+14 -2
View File
@@ -138,6 +138,11 @@ final class FakeAdapter implements SourceAdapter
return $this->releases; return $this->releases;
} }
public function sourceArchiveUrl(array $repository, string $commitSha): ?string
{
return 'https://git.mrblake.cc/' . $repository['fullName'] . '/archive/' . $commitSha . '.tar.gz';
}
public function hashArtifact(string $url, string $expectedSha256 = ''): array public function hashArtifact(string $url, string $expectedSha256 = ''): array
{ {
$this->hashCalls++; $this->hashCalls++;
@@ -232,6 +237,7 @@ function approvedFixture(): array
'title' => '1.2.3', 'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl', 'title' => '1.2.3', 'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64), 'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64),
'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel', 'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel',
'artifactFilename' => 'netbox_demo-1.2.3-py3-none-any.whl',
'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z', 'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z',
'draft' => false, 'withdrawn' => false, 'status' => 'pending', 'draft' => false, 'withdrawn' => false, 'status' => 'pending',
'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '', 'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '',
@@ -432,7 +438,7 @@ test('Forgejo and GitHub account repository and release pages before accumulatio
test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void { test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void {
$state = approvedFixture(); $state = approvedFixture();
$releaseKeys = [ $releaseKeys = [
'version', 'download_url', 'sha256', 'artifact_size', 'commit_sha', 'min_netbox_version', 'version', 'download_url', 'sha256', 'artifact_size', 'artifact_kind', 'artifact_filename', 'commit_sha', 'min_netbox_version',
'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256', 'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256',
]; ];
$pluginKeys = [ $pluginKeys = [
@@ -484,6 +490,9 @@ test('public and admin templates render safely with complete artifact evidence',
'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'], 'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'],
]); ]);
assertTrue(str_contains($detail, 'Wheel')); assertTrue(str_contains($detail, 'Wheel'));
$installation = $view->render('installation', $common + ['title' => 'Installation']);
assertTrue(str_contains($installation, 'git+https://git.mrblake.cc/MrBlake/Netbox-Store.git'));
assertTrue(str_contains($installation, 'https://netbox.mrblake.cc'));
$admin = $view->render('admin/dashboard', [ $admin = $view->render('admin/dashboard', [
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin', 'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'], 'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
@@ -824,9 +833,12 @@ test('sync preserves overrides, rehashes replacements, withdraws removals and ar
}); });
$adapter->releases = []; $adapter->releases = [];
$service->syncSource('source-sync'); $service->syncSource('source-sync');
$withdrawn = $repository->read()['releases'][0]; $fallbackState = $repository->read();
$withdrawn = $fallbackState['releases'][0];
assertTrue($withdrawn['withdrawn']); assertTrue($withdrawn['withdrawn']);
assertSame('pending', $withdrawn['status']); assertSame('pending', $withdrawn['status']);
assertSame('source_archive', $fallbackState['releases'][1]['artifactKind']);
assertSame('source:' . str_repeat('c', 40), $fallbackState['releases'][1]['externalId']);
$adapter->files = []; $adapter->files = [];
$service->syncSource('source-sync'); $service->syncSource('source-sync');