Files
pi-pm3/app/backend/managers/update_manager.py
michael 2ec89041ef Phase 4 enhancements: WebSocket auth, HTTPS UI, plugin hooks, build fixes
Security:
- Add token-based WebSocket authentication (closes critical security gap)
  - In-memory token store with 24h TTL (token_store.py)
  - POST /api/auth/token exchanges Basic Auth for WS token
  - GET /api/auth/status public endpoint for auth check
  - WebSocket validates token query param, rejects with close code 4401
  - Frontend LoginPrompt modal for credential entry
  - WebSocket manager handles full auth flow with auth_required state
  - No-op when AUTH_ENABLED=false (preserves existing behavior)

HTTPS:
- Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle)
- Add certificate regeneration button
- Display SSL info (expiration, SANs, SHA256 fingerprint)

Plugins:
- Wire trigger_hook("pm3_command") in PM3 service
- Wire trigger_hook("update_check") in update manager

Build/Infrastructure:
- Enable NetworkManager in pi-gen AP setup stage
- Add HF booster board detection patch for Proxmark3
- Update LED PWM control patch
- Fix BLE adapter, UPS drivers, WiFi manager improvements
- Update HTTPS support stage script

Documentation:
- Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:45:11 -08:00

487 lines
16 KiB
Python

"""Update Manager for Dangerous Pi.
Handles checking for updates from GitHub releases, downloading,
and applying updates to the system.
"""
import asyncio
import hashlib
import json
import os
import re
import shutil
import tempfile
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List
import aiohttp
import aiosqlite
from .. import config
class UpdateStatus(str, Enum):
"""Update status enum."""
IDLE = "idle"
CHECKING = "checking"
AVAILABLE = "available"
DOWNLOADING = "downloading"
INSTALLING = "installing"
COMPLETE = "complete"
FAILED = "failed"
@dataclass
class ReleaseInfo:
"""GitHub release information."""
version: str
tag_name: str
published_at: str
download_url: str
changelog: str
is_prerelease: bool
asset_name: str
asset_size: int
checksum: Optional[str] = None
@dataclass
class UpdateProgress:
"""Update progress information."""
status: UpdateStatus
current_version: str
available_version: Optional[str] = None
download_progress: float = 0.0
error_message: Optional[str] = None
last_check: Optional[str] = None
class UpdateManager:
"""Manages system updates from GitHub releases."""
def __init__(self):
"""Initialize the update manager."""
self._current_version = config.VERSION
self._github_repo = config.GITHUB_REPO
self._github_api_base = "https://api.github.com"
self._status = UpdateStatus.IDLE
self._progress = UpdateProgress(
status=UpdateStatus.IDLE,
current_version=self._current_version
)
self._latest_release: Optional[ReleaseInfo] = None
self._update_lock = asyncio.Lock()
self._download_path: Optional[Path] = None
self._check_interval = config.UPDATE_CHECK_INTERVAL
async def start_periodic_checks(self):
"""Start periodic update checks in background."""
while True:
try:
await self.check_for_updates()
await asyncio.sleep(self._check_interval)
except asyncio.CancelledError:
break
except Exception as e:
print(f"Error in periodic update check: {e}")
await asyncio.sleep(self._check_interval)
async def check_for_updates(self) -> Dict[str, Any]:
"""Check for available updates from GitHub.
Returns:
Dict containing update status and info
"""
async with self._update_lock:
try:
self._status = UpdateStatus.CHECKING
self._progress.status = UpdateStatus.CHECKING
# Fetch latest release from GitHub
release = await self._fetch_latest_release()
if not release:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
self._progress.last_check = datetime.utcnow().isoformat()
return {
"update_available": False,
"current_version": self._current_version,
"message": "No releases found"
}
# Trigger plugin hooks (non-critical)
try:
from .plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("update_check")
except Exception:
pass
# Compare versions
if self._is_newer_version(release.version, self._current_version):
self._latest_release = release
self._status = UpdateStatus.AVAILABLE
self._progress.status = UpdateStatus.AVAILABLE
self._progress.available_version = release.version
return {
"update_available": True,
"current_version": self._current_version,
"latest_version": release.version,
"release_date": release.published_at,
"changelog": release.changelog,
"is_prerelease": release.is_prerelease,
"download_size": release.asset_size
}
else:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
self._progress.last_check = datetime.utcnow().isoformat()
return {
"update_available": False,
"current_version": self._current_version,
"latest_version": release.version,
"message": "System is up to date"
}
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
raise Exception(f"Failed to check for updates: {e}")
async def download_update(self) -> bool:
"""Download the latest update.
Returns:
True if download successful, False otherwise
"""
async with self._update_lock:
if not self._latest_release:
raise ValueError("No update available to download")
try:
self._status = UpdateStatus.DOWNLOADING
self._progress.status = UpdateStatus.DOWNLOADING
self._progress.download_progress = 0.0
# Create temp directory for download
temp_dir = Path(tempfile.mkdtemp(prefix="dangerous-pi-update-"))
self._download_path = temp_dir / self._latest_release.asset_name
# Download the release asset
async with aiohttp.ClientSession() as session:
async with session.get(self._latest_release.download_url) as response:
if response.status != 200:
raise Exception(f"Download failed with status {response.status}")
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(self._download_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
self._progress.download_progress = (downloaded / total_size) * 100
# Verify checksum if available
if self._latest_release.checksum:
if not await self._verify_checksum(self._download_path, self._latest_release.checksum):
raise Exception("Checksum verification failed")
self._progress.download_progress = 100.0
return True
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
# Cleanup on failure
if self._download_path and self._download_path.parent.exists():
shutil.rmtree(self._download_path.parent, ignore_errors=True)
raise Exception(f"Failed to download update: {e}")
async def install_update(self) -> bool:
"""Install the downloaded update.
Returns:
True if installation successful, False otherwise
"""
async with self._update_lock:
if not self._download_path or not self._download_path.exists():
raise ValueError("No update downloaded")
try:
self._status = UpdateStatus.INSTALLING
self._progress.status = UpdateStatus.INSTALLING
# Extract the update archive
install_dir = Path("/opt/dangerous-pi")
backup_dir = Path("/opt/dangerous-pi-backup")
# Create backup of current installation
if install_dir.exists():
if backup_dir.exists():
shutil.rmtree(backup_dir)
shutil.copytree(install_dir, backup_dir)
# Extract update (assuming tar.gz format)
await self._run_command(
f"tar -xzf {self._download_path} -C {install_dir.parent}"
)
# Run post-install script if exists
post_install = install_dir / "scripts" / "post-install.sh"
if post_install.exists():
await self._run_command(f"sudo bash {post_install}")
# Rebuild PM3 client if needed
await self._rebuild_pm3_client()
# Update version in config
await self._update_version_file(self._latest_release.version)
# Cleanup
shutil.rmtree(self._download_path.parent, ignore_errors=True)
self._download_path = None
self._status = UpdateStatus.COMPLETE
self._progress.status = UpdateStatus.COMPLETE
self._current_version = self._latest_release.version
self._progress.current_version = self._latest_release.version
return True
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
# Restore backup on failure
if backup_dir.exists():
if install_dir.exists():
shutil.rmtree(install_dir)
shutil.copytree(backup_dir, install_dir)
raise Exception(f"Failed to install update: {e}")
async def get_progress(self) -> UpdateProgress:
"""Get current update progress.
Returns:
UpdateProgress object
"""
return self._progress
async def get_release_notes(self, version: Optional[str] = None) -> str:
"""Get release notes for a specific version.
Args:
version: Version to get notes for (latest if None)
Returns:
Release notes as markdown string
"""
try:
if version:
release = await self._fetch_release_by_version(version)
else:
release = await self._fetch_latest_release()
return release.changelog if release else "No release notes available"
except Exception as e:
raise Exception(f"Failed to get release notes: {e}")
async def _fetch_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch latest release from GitHub API.
Returns:
ReleaseInfo object or None
"""
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/latest"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(f"GitHub API returned status {response.status}")
data = await response.json()
return self._parse_release_data(data)
async def _fetch_release_by_version(self, version: str) -> Optional[ReleaseInfo]:
"""Fetch specific release by version tag.
Args:
version: Version tag (e.g., "v1.0.0")
Returns:
ReleaseInfo object or None
"""
tag = version if version.startswith('v') else f'v{version}'
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/tags/{tag}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(f"GitHub API returned status {response.status}")
data = await response.json()
return self._parse_release_data(data)
def _parse_release_data(self, data: Dict[str, Any]) -> ReleaseInfo:
"""Parse GitHub release API response.
Args:
data: GitHub API release data
Returns:
ReleaseInfo object
"""
# Find the main release asset (tar.gz)
assets = data.get('assets', [])
main_asset = None
checksum_asset = None
for asset in assets:
name = asset['name']
if name.endswith('.tar.gz'):
main_asset = asset
elif name.endswith('.sha256'):
checksum_asset = asset
if not main_asset:
raise ValueError("No suitable release asset found")
# Get version from tag (remove 'v' prefix)
version = data['tag_name'].lstrip('v')
# Get checksum if available
checksum = None
if checksum_asset:
# Would need to download and read checksum file
# For now, we'll skip this step
pass
return ReleaseInfo(
version=version,
tag_name=data['tag_name'],
published_at=data['published_at'],
download_url=main_asset['browser_download_url'],
changelog=data.get('body', ''),
is_prerelease=data.get('prerelease', False),
asset_name=main_asset['name'],
asset_size=main_asset['size'],
checksum=checksum
)
def _is_newer_version(self, version1: str, version2: str) -> bool:
"""Compare two semantic versions.
Args:
version1: First version (e.g., "1.2.3")
version2: Second version (e.g., "1.1.0")
Returns:
True if version1 is newer than version2
"""
def parse_version(v: str) -> tuple:
# Remove 'v' prefix and split
v = v.lstrip('v')
parts = re.split(r'[-+]', v)[0] # Remove pre-release/build metadata
return tuple(map(int, parts.split('.')))
try:
v1_parts = parse_version(version1)
v2_parts = parse_version(version2)
return v1_parts > v2_parts
except (ValueError, AttributeError):
return False
async def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool:
"""Verify file checksum.
Args:
file_path: Path to file to verify
expected_checksum: Expected SHA256 checksum
Returns:
True if checksum matches, False otherwise
"""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
data = f.read(65536) # 64KB chunks
if not data:
break
sha256.update(data)
actual_checksum = sha256.hexdigest()
return actual_checksum.lower() == expected_checksum.lower()
async def _rebuild_pm3_client(self):
"""Rebuild Proxmark3 client after update."""
pm3_dir = Path("/opt/proxmark3")
if not pm3_dir.exists():
print("Proxmark3 directory not found, skipping rebuild")
return
# Run make clean and make
await self._run_command(f"cd {pm3_dir} && make clean && make")
async def _update_version_file(self, version: str):
"""Update version file with new version.
Args:
version: New version string
"""
version_file = Path("/opt/dangerous-pi/VERSION")
version_file.write_text(version)
async def _run_command(self, command: str) -> str:
"""Run a shell command.
Args:
command: Command to run
Returns:
Command output
"""
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise Exception(f"Command failed: {stderr.decode()}")
return stdout.decode()
# Global update manager instance
_update_manager: Optional[UpdateManager] = None
def get_update_manager() -> UpdateManager:
"""Get the global update manager instance."""
global _update_manager
if _update_manager is None:
_update_manager = UpdateManager()
return _update_manager