Download approved plugins from GitHub Releases

This commit is contained in:
yellowcooln committed 2026-09-04 13:08:09 -04:00
1 parent eb8f934124
commit c6098f88e3
5 files changed
+118 -56

No files matched your search

+12 -10
View File
@@ -246,9 +246,10 @@ curl -X DELETE -H "Authorization: Bearer $TOKEN" \
## Plugin catalogue
Repeater browses a curated static catalogue and installs the exact plugin wheel
approved there. The catalogue and wheels are hosted on openHop's R2-backed
origin, so normal installs and update checks do not call the GitHub API.
Repeater browses the curated static catalogue from openHop's R2-backed endpoint.
Update checks read only that catalogue and do not query the GitHub API. When a
user installs an approved plugin, Repeater downloads the exact wheel directly
from the plugin's GitHub Release.
Default catalogue URL:
@@ -264,11 +265,12 @@ plugins:
```
Catalogue schema 2 records each plugin's currently approved `version`, exact
`wheel_url`, and lowercase `sha256`. The manager downloads only from
`https://repeater-plugins.openhop.dev/plugins/`, verifies the checksum before
installation, and confirms the wheel manifest ID and version match the
catalogue. Publishing a newer GitHub Release does not make it available until
the catalogue maintainers approve and publish that exact wheel.
GitHub Release `wheel_url`, and lowercase `sha256`. The manager validates that
the URL belongs to the plugin repository, follows only GitHub's HTTPS release
asset redirect, verifies the checksum before installation, and confirms the
wheel manifest ID and version match the catalogue. Publishing a newer GitHub
Release does not make it available until the catalogue maintainers approve its
metadata in the R2 catalogue.
Schema 1 repository-only catalogues remain readable for compatibility with
custom deployments, but the default openHop catalogue uses schema 2 and needs
@@ -297,6 +299,6 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8000/api/plugins/update
```
R2 catalogue or wheel outages do not affect already-installed plugins or
Repeater startup. Local `.whl` install continues to work unchanged.
R2 catalogue or GitHub Release download outages do not affect already-installed
plugins or Repeater startup. Local `.whl` install continues to work unchanged.
+56 -16
View File
@@ -23,7 +23,8 @@ DEFAULT_CATALOGUE_URL = "https://repeater-plugins.openhop.dev/catalogue.json"
DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes
SUPPORTED_SCHEMAS = (1, 2)
USER_AGENT = "openhop-repeater-plugin-manager/1.0"
APPROVED_WHEEL_ORIGIN = "https://repeater-plugins.openhop.dev"
GITHUB_RELEASE_ORIGIN = "https://github.com"
GITHUB_RELEASE_ASSET_HOST = "release-assets.githubusercontent.com"
MAX_WHEEL_BYTES = 100 * 1024 * 1024
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
@@ -45,24 +46,62 @@ def _ssl_context() -> ssl.SSLContext:
return _ssl_ctx
def _validate_approved_wheel_url(plugin_id: str, version: str, wheel_url: str) -> None:
def _validate_approved_wheel_url(repository: str, version: str, wheel_url: str) -> None:
parsed = urlsplit(wheel_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
expected_prefix = f"/plugins/{plugin_id}/{version}/"
expected_prefix = f"/{repository}/releases/download/"
path_tail = (
parsed.path[len(expected_prefix) :] if parsed.path.startswith(expected_prefix) else ""
)
path_parts = path_tail.split("/")
filename = path_parts[-1] if path_parts else ""
if (
origin != APPROVED_WHEEL_ORIGIN
or not parsed.path.startswith(expected_prefix)
or not parsed.path.endswith(".whl")
origin != GITHUB_RELEASE_ORIGIN
or len(path_parts) != 2
or not path_parts[0]
or not filename.endswith(".whl")
or f"-{version}-" not in filename
or parsed.query
or parsed.fragment
):
raise CatalogueError(
f"wheel_url must be an approved R2 wheel under "
f"{APPROVED_WHEEL_ORIGIN}{expected_prefix}",
f"wheel_url must be a version-matched GitHub Release wheel in {repository}",
code=400,
)
def _validate_redirect_target(source_url: str, target_url: str) -> None:
source = urlsplit(source_url)
target = urlsplit(target_url)
same_origin = (
source.scheme == "https" and target.scheme == "https" and source.netloc == target.netloc
)
github_asset_redirect = (
source.scheme == "https"
and source.hostname in {"github.com", GITHUB_RELEASE_ASSET_HOST}
and target.scheme == "https"
and target.hostname == GITHUB_RELEASE_ASSET_HOST
and target.port is None
)
if not (same_origin or github_asset_redirect):
raise CatalogueError(
f"download redirect is not allowed: {source_url} -> {target_url}",
code=502,
)
def _validate_download_response_url(source_url: str, final_url: str) -> None:
if final_url == source_url:
return
_validate_redirect_target(source_url, final_url)
class _ApprovedRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
_validate_redirect_target(req.full_url, newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
@dataclass(frozen=True)
class CataloguePlugin:
id: str
@@ -204,7 +243,7 @@ def parse_catalogue(data: Any) -> Catalogue:
wheel_url = raw_wheel_url.strip()
sha256 = raw_sha256
try:
_validate_approved_wheel_url(plugin_id, version, wheel_url)
_validate_approved_wheel_url(repository, version, wheel_url)
except CatalogueError as exc:
raise CatalogueError(f"plugins[{idx}].{exc}", code=exc.code) from exc
if wheel_url in seen_wheel_urls:
@@ -248,9 +287,11 @@ class CatalogueClient:
self._cached: Optional[Catalogue] = None
def _default_open(self, request: urllib.request.Request, timeout: float = 30.0):
return urllib.request.urlopen( # nosec B310 - validated HTTPS URLs
request, timeout=timeout, context=_ssl_context()
opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=_ssl_context()),
_ApprovedRedirectHandler(),
)
return opener.open(request, timeout=timeout) # nosec B310 - validated HTTPS URLs
def clear_cache(self) -> None:
self._cached = None
@@ -310,13 +351,13 @@ class CatalogueClient:
return plugin
def download_wheel(self, plugin: CataloguePlugin, dest_dir: Path | str) -> Path:
"""Download and verify one schema-2 approved wheel from R2."""
"""Download and verify one schema-2 approved GitHub Release wheel."""
if not plugin.version or not plugin.wheel_url or not plugin.sha256:
raise CatalogueError(
f"catalogue entry {plugin.id} does not contain approved wheel metadata",
code=400,
)
_validate_approved_wheel_url(plugin.id, plugin.version, plugin.wheel_url)
_validate_approved_wheel_url(plugin.repository, plugin.version, plugin.wheel_url)
if not _SHA256_RE.fullmatch(plugin.sha256):
raise CatalogueError(f"invalid approved checksum for {plugin.id}", code=400)
@@ -339,9 +380,8 @@ class CatalogueClient:
try:
with self._opener(request, timeout=120.0) as response:
final_url = getattr(response, "geturl", lambda: plugin.wheel_url)()
_validate_approved_wheel_url(
plugin.id,
plugin.version,
_validate_download_response_url(
plugin.wheel_url,
final_url or plugin.wheel_url,
)
while True:
+2 -2
View File
@@ -4626,8 +4626,8 @@ paths:
- Plugins
summary: Install a plugin from the catalogue
description: >
Download the exact R2 wheel approved by catalogue version and SHA-256,
verify its manifest, install it, and enable the plugin.
Download the exact GitHub Release wheel approved by the R2 catalogue
version and SHA-256, verify its manifest, install it, and enable the plugin.
requestBody:
required: true
content:
+20 -2
View File
@@ -14,6 +14,7 @@ from repeater.plugins.catalogue import (
DEFAULT_CATALOGUE_URL,
CatalogueClient,
CatalogueError,
_validate_redirect_target,
parse_catalogue,
)
@@ -42,7 +43,7 @@ VALID_V2 = {
"repository": "openhop-dev/openhop-nomad-plugin",
"version": "0.1.0",
"wheel_url": (
"https://repeater-plugins.openhop.dev/plugins/openhop.nomad/0.1.0/"
"https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/"
"openhop_nomad_plugin-0.1.0-py3-none-any.whl"
),
"sha256": hashlib.sha256(APPROVED_WHEEL).hexdigest(),
@@ -99,11 +100,28 @@ def test_download_v2_wheel_rejects_redirect_outside_approved_origin(tmp_path):
return _FakeResp(APPROVED_WHEEL, final_url="https://example.com/plugin.whl")
plugin = parse_catalogue(VALID_V2).plugins[0]
with pytest.raises(CatalogueError, match="approved R2"):
with pytest.raises(CatalogueError, match="not allowed"):
CatalogueClient(opener=opener).download_wheel(plugin, tmp_path)
assert list(tmp_path.iterdir()) == []
def test_github_release_asset_redirect_is_allowed():
_validate_redirect_target(
"https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/"
"openhop_nomad_plugin-0.1.0-py3-none-any.whl",
"https://release-assets.githubusercontent.com/github-production-release-asset.whl",
)
def test_https_downgrade_redirect_is_rejected():
with pytest.raises(CatalogueError, match="not allowed"):
_validate_redirect_target(
"https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/"
"openhop_nomad_plugin-0.1.0-py3-none-any.whl",
"http://release-assets.githubusercontent.com/asset.whl",
)
def test_unsupported_schema():
with pytest.raises(CatalogueError, match="unsupported catalogue schema"):
parse_catalogue({"schema": 99, "plugins": []})
@@ -1,4 +1,4 @@
"""Approved R2 catalogue install and update flows."""
"""R2 catalogue lookup with approved GitHub Release install and update flows."""
from __future__ import annotations
@@ -63,7 +63,7 @@ def _entry(version: str, wheel: bytes) -> dict:
"category": "test",
"version": version,
"wheel_url": (
f"https://repeater-plugins.openhop.dev/plugins/openhop.demo/{version}/"
f"https://github.com/openhop-dev/demo-plugin/releases/download/v{version}/"
f"demo-{version}-py3-none-any.whl"
),
"sha256": hashlib.sha256(wheel).hexdigest(),
@@ -71,7 +71,7 @@ def _entry(version: str, wheel: bytes) -> dict:
@pytest.fixture
def r2_manager(tmp_path: Path):
def catalogue_manager(tmp_path: Path):
wheels = {
"0.1.0": _make_wheel(tmp_path / "demo-0.1.0.whl", "openhop.demo", "0.1.0"),
"0.2.0": _make_wheel(tmp_path / "demo-0.2.0.whl", "openhop.demo", "0.2.0"),
@@ -94,48 +94,50 @@ def r2_manager(tmp_path: Path):
catalogue_client=catalogue,
github_client=github,
)
manager._r2_test_state = state # type: ignore[attr-defined]
manager._r2_test_wheels = wheels # type: ignore[attr-defined]
manager._catalogue_test_state = state # type: ignore[attr-defined]
manager._catalogue_test_wheels = wheels # type: ignore[attr-defined]
return manager
def test_schema2_install_uses_only_approved_r2_wheel(r2_manager: PluginManager):
status = r2_manager.install_from_catalogue("openhop.demo")
def test_schema2_install_uses_only_approved_github_wheel(catalogue_manager: PluginManager):
status = catalogue_manager.install_from_catalogue("openhop.demo")
assert status["version"] == "0.1.0"
assert status["enabled"] is True
assert status["source"] == "catalogue"
assert all("api.github.com" not in url for url in r2_manager._r2_test_state["requests"])
r2_manager.github.latest_stable.assert_not_called()
r2_manager.github.download_latest_wheel.assert_not_called()
assert all(
"api.github.com" not in url for url in catalogue_manager._catalogue_test_state["requests"]
)
catalogue_manager.github.latest_stable.assert_not_called()
catalogue_manager.github.download_latest_wheel.assert_not_called()
def test_schema2_catalogue_version_controls_updates(r2_manager: PluginManager):
r2_manager.install_from_catalogue("openhop.demo")
wheels = r2_manager._r2_test_wheels
r2_manager._r2_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"])
r2_manager.catalogue.clear_cache()
def test_schema2_catalogue_version_controls_updates(catalogue_manager: PluginManager):
catalogue_manager.install_from_catalogue("openhop.demo")
wheels = catalogue_manager._catalogue_test_wheels
catalogue_manager._catalogue_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"])
catalogue_manager.catalogue.clear_cache()
check = r2_manager.check_update("openhop.demo", force_refresh=True)
check = catalogue_manager.check_update("openhop.demo", force_refresh=True)
assert check["latestVersion"] == "0.2.0"
assert check["updateAvailable"] is True
updated = r2_manager.update_plugin("openhop.demo", force_refresh=True)
updated = catalogue_manager.update_plugin("openhop.demo", force_refresh=True)
assert updated["version"] == "0.2.0"
assert updated["enabled"] is True
r2_manager.github.latest_stable.assert_not_called()
r2_manager.github.download_wheel.assert_not_called()
catalogue_manager.github.latest_stable.assert_not_called()
catalogue_manager.github.download_wheel.assert_not_called()
def test_schema2_rejects_unapproved_requested_version(r2_manager: PluginManager):
def test_schema2_rejects_unapproved_requested_version(catalogue_manager: PluginManager):
with pytest.raises(PluginManagerError, match="not approved"):
r2_manager.install_from_catalogue("openhop.demo", version="9.9.9")
catalogue_manager.install_from_catalogue("openhop.demo", version="9.9.9")
def test_schema2_checksum_failure_does_not_install(r2_manager: PluginManager):
r2_manager._r2_test_state["entry"]["sha256"] = "0" * 64
r2_manager.catalogue.clear_cache()
def test_schema2_checksum_failure_does_not_install(catalogue_manager: PluginManager):
catalogue_manager._catalogue_test_state["entry"]["sha256"] = "0" * 64
catalogue_manager.catalogue.clear_cache()
with pytest.raises(PluginManagerError, match="checksum"):
r2_manager.install_from_catalogue("openhop.demo", force_refresh=True)
assert r2_manager.storage.list_plugin_ids() == []
catalogue_manager.install_from_catalogue("openhop.demo", force_refresh=True)
assert catalogue_manager.storage.list_plugin_ids() == []