Skip to content
52 changes: 52 additions & 0 deletions moonraker/components/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,58 @@ async def github_api_request(
resp_hdrs['X-Ratelimit-Reset'])
return resp

async def http_api_request(
self,
resource: str,
attempts: int = 1,
retry_pause_time: float = .1,
headers: Optional[Dict[str, str]] = None
) -> HttpResponse:

if resource.startswith(('http://', 'https://')):
url = resource
is_github = "github.com" in url
else:

url = f"{GITHUB_PREFIX}{resource.strip('/')}"
is_github = True

limit_remaining = getattr(self, 'gh_limit_remaining', 1)
limit_reset_time = getattr(self, 'gh_limit_reset_time', None)

if limit_reset_time and limit_remaining == 0:
if time.time() < limit_reset_time:
reset_str = time.ctime(limit_reset_time)
raise self.server.error(
f"Rate Limit Reached\n"
f"Request: {url}\n"
f"Reset at: {reset_str}")

request_headers = {"Accept": "application/json"}
if is_github:
request_headers["Accept"] = "application/vnd.github.v3+json"

if headers:
request_headers.update(headers)

resp = await self.get(
url,
headers=request_headers,
attempts=attempts,
retry_pause_time=retry_pause_time
)

hdrs = resp.headers
rem = hdrs.get('X-Ratelimit-Remaining') or hdrs.get('RateLimit-Remaining')
reset = hdrs.get('X-Ratelimit-Reset') or hdrs.get('RateLimit-Reset')

if rem is not None:
self.gh_limit_remaining = int(rem)
if reset is not None:
self.gh_limit_reset_time = float(reset)

return resp

def github_api_stats(self) -> Dict[str, Any]:
return {
'github_rate_limit': self.gh_rate_limit,
Expand Down
174 changes: 153 additions & 21 deletions moonraker/components/update_manager/net_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .common import Channel, AppType
from ...utils import source_info
from ...utils import json_wrapper as jsonw
from ...utils.gpg_tool import GPGTool

# Annotation imports
from typing import (
Expand Down Expand Up @@ -46,6 +47,16 @@ def __init__(self, config: ConfigHelper) -> None:
self._configure_managed_services(config)
self.repo = config.get('repo').strip().strip("/")
self.owner, self.project_name = self.repo.split("/", 1)

self.enable_mirror = config.getboolean('enable_mirror', False)
self.mirror_url = config.get('mirror_url', "")
self.mirror_latest_template = config.get(
'mirror_latest_template', "LatestRelease/release"
)
self.mirror_tag_template = config.get(
'mirror_tag_template', "{tag}/release"
)

self.asset_name: Optional[str] = None
self.persistent_files: List[str] = []
self.warnings: List[str] = []
Expand Down Expand Up @@ -91,6 +102,18 @@ async def _validate_release_info(self) -> None:
self._is_fallback = False
eventloop = self.server.get_event_loop()
self.warnings.clear()
self.anomalies.clear()

# mirror override
if self.enable_mirror:
self._is_valid = True
self._is_fallback = True

self.anomalies.append(
f"Mirror url: {self.mirror_url}"
)
return

repo_parent = source_info.find_git_repo(self.path)
homedir = pathlib.Path("~").expanduser()
if not self._path_writable:
Expand Down Expand Up @@ -200,7 +223,7 @@ async def initialize(self) -> Dict[str, Any]:
fm.add_reserved_path(f"update_manager {self.name}", self.path)
await self._validate_release_info()
if self.version == "?":
self.version = storage.get("version", "?")
self.version = storage.get("version", "v0.0.0")
self.remote_version = storage.get('remote_version', "?")
self.rollback_version = storage.get('rollback_version', self.version)
self.rollback_repo = storage.get(
Expand Down Expand Up @@ -242,16 +265,52 @@ async def _fetch_github_version(
self.log_info("Invalid Installation, aborting remote refresh")
return {}
repo = self.repo
if tag is not None:
resource = f"repos/{repo}/releases/tags/{tag}"
elif self.channel == Channel.STABLE:
resource = f"repos/{repo}/releases/latest"

# mirror enable
if self.enable_mirror:

try:
if tag is not None:
# Rendering rollback / Specific version URL
mirror_tag_release = self.mirror_tag_template.format(
tag=tag
)

# tag version
resource = (
f"{self.mirror_url.rstrip('/')}/"
f"{mirror_tag_release.lstrip('/')}"
)
else:
# latest release
resource = (
f"{self.mirror_url.rstrip('/')}/"
f"{self.mirror_latest_template.lstrip('/')}"
)

except KeyError as e:
self.log_info(f"Mirror template error: missing key {e}")
return {}
else:
resource = f"repos/{repo}/releases?per_page=1"
if tag is not None:
resource = f"repos/{repo}/releases/tags/{tag}"
elif self.channel == Channel.STABLE:
resource = f"repos/{repo}/releases/latest"
else:
resource = f"repos/{repo}/releases?per_page=1"

# init http client
client = self.cmd_helper.get_http_client()
resp = await client.github_api_request(
resource, attempts=3, retry_pause_time=.5
)

if self.enable_mirror:
resp = await client.http_api_request(
resource, attempts=3, retry_pause_time=.5
)
else:
resp = await client.github_api_request(
resource, attempts=3, retry_pause_time=.5
)

release: Union[List[Any], Dict[str, Any]] = {}
if resp.status_code == 304:
if resp.content:
Expand Down Expand Up @@ -280,16 +339,27 @@ async def _get_remote_version(self) -> None:
result = await self._fetch_github_version()
if not result:
return

self.remote_version = result.get('name', "?")
assets: List[Dict[str, Any]] = result.get("assets", [{}])
release_asset: Dict[str, Any] = assets[0] if assets else {}
if self.asset_name is not None:
for asset in assets:
if asset.get("name", "") == self.asset_name:
release_asset = asset
break
else:
logging.info(f"Asset '{self.asset_name}' not found")

assets: List[Dict[str, Any]] = result.get("assets") or []
if not assets:
logging.info("No assets found in release")
return

release_asset: Dict[str, Any] = {}

target_name = f"{self.project_name}.zip"

for asset in assets:
if asset.get("name") == target_name:
release_asset = asset
break

if not release_asset:
logging.info(f"Asset '{target_name}' not found")
return

dl_url: str = release_asset.get('browser_download_url', "?")
content_type: str = release_asset.get('content_type', "?")
size: int = release_asset.get('size', 0)
Expand Down Expand Up @@ -318,7 +388,11 @@ def _log_app_info(self):
f"Download Size: {size}\n"
f"Content Type: {content_type}\n"
f"Rollback Version: {self.rollback_version}\n"
f"Rollback Repo: {self.rollback_repo}"
f"Rollback Repo: {self.rollback_repo}\n"
f"enable_mirror: {self.enable_mirror}\n"
f"mirror_url: {self.mirror_url}\n"
f"mirror_latest_template: {self.mirror_latest_template}\n"
f"mirror_tag_template: {self.mirror_tag_template}\n"
f"{warn_str}"
)

Expand Down Expand Up @@ -438,6 +512,45 @@ async def update(
dl_url, content_type, temp_download_file, size,
self.cmd_helper.on_download_progress
)

if self.enable_mirror:
# get signature asc file
sig_url = f"{dl_url}.asc"
sig_file = tempdir.joinpath(temp_download_file.name + ".asc")

try:
await client.download_file(
sig_url,
"text/plain",
sig_file,
-1,
self.cmd_helper.on_download_progress
)

except Exception:
raise self.server.error(f"signature not found {sig_url}")

self.notify_status("Verifying signature...")

verifier = GPGTool()

try:
ok = verifier.verify_with_keychain(
owner=self.owner,
project_name=self.project_name,
sig_file=sig_file,
data_file=temp_download_file,
)

if not ok:
raise self.server.error(
f"{self.prefix}Signature verification failed"
)
else:
self.notify_status("signature Verifyed")
finally:
verifier.cleanup()

self.notify_status(
f"Download Complete, extracting release to '{self.path}'"
)
Expand Down Expand Up @@ -471,14 +584,33 @@ async def recover(
async def rollback(self) -> bool:
if self.rollback_version == "?" or self.rollback_repo == "?":
raise self.server.error("Incomplete Rollback Data", False)

if self.rollback_version == self.version:
return False

result = await self._fetch_github_version(
self.rollback_repo, self.rollback_version
self.rollback_repo,
self.rollback_version
)

if not result:
raise self.server.error("Failed to retrieve release asset data")
release_asset: Dict[str, Any] = result.get('assets', [{}])[0]

assets: List[Dict[str, Any]] = result.get("assets") or []
if not assets:
raise self.server.error("No assets found in rollback release")

target_name = f"{self.project_name}.zip"

release_asset: Dict[str, Any] = {}
for asset in assets:
if asset.get("name") == target_name:
release_asset = asset
break

if not release_asset:
raise self.server.error(f"Asset '{target_name}' not found")

dl_url: str = release_asset.get('browser_download_url', "?")
content_type: str = release_asset.get('content_type', "?")
size: int = release_asset.get('size', 0)
Expand Down
61 changes: 61 additions & 0 deletions moonraker/utils/gpg_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from pathlib import Path
import subprocess
import tempfile
import shutil

class GPGTool:
def __init__(self):
self.tmpdir = Path(tempfile.mkdtemp(prefix="gpgv_tmp_"))
self.keyring = self.tmpdir / "pubring.gpg"

@staticmethod
def get_moonraker_root() -> Path:
return Path(__file__).resolve().parents[2]

def build_keyring(self, key_file: Path):
cmd = [
"gpg",
"--batch",
"--yes",
"--dearmor",
"-o",
str(self.keyring),
str(key_file),
]

res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(res.stderr)

def verify(self, sig_file: Path, data_file: Path):
cmd = [
"gpgv",
"--keyring",
str(self.keyring),
str(sig_file),
str(data_file),
]

res = subprocess.run(cmd, capture_output=True, text=True)

return res.returncode == 0, res.stderr

def verify_with_keychain(self, owner: str, project_name: str,
sig_file: Path, data_file: Path) -> bool:
key_file = (
self.get_moonraker_root()
/ "keychain"
/ owner
/ f"{project_name}.asc"
)

if not key_file.exists():
raise FileNotFoundError(f"Key not found: {key_file}")

self.build_keyring(key_file)

ok, _ = self.verify(sig_file, data_file)
return True if ok else False

def cleanup(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
Loading