mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
Download approved plugins from GitHub Releases
This commit is contained in:
1 parent
eb8f934124
commit
c6098f88e3
5 files changed
+118
-56
No files matched your search
+12
-10
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []})
|
||||
|
||||
+28
-26
@@ -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() == []
|
||||
Reference in new issue
Block a user