"""Panel self-update from the EvilFox Gitea repository (git or release archive).""" from __future__ import annotations import json import logging import os import re import shutil import subprocess import sys import tempfile import threading import time import urllib.error import urllib.request import zipfile from typing import Optional logger = logging.getLogger(__name__) DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main' DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git' DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest' UPDATE_REMOTE = 'panel-update' ARCHIVE_SKIP_DIRS = frozenset({ '__pycache__', '.git', 'bin', 'data', 'node_modules', '.venv', 'venv', '.cursor', }) ARCHIVE_SKIP_FILES = frozenset({ '.env', 'data.json', 'tunnels_state.json', }) def _env(name: str, default: str) -> str: return (os.environ.get(name) or default).strip() def repo_url() -> str: return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/') def git_url() -> str: return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL) def api_latest_url() -> str: return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST) def parse_version(value: str) -> tuple: """Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple.""" raw = (value or '').strip() if not raw: return (0, 0, 0, 1, '') raw = raw.lstrip('vV') main, _, pre = raw.partition('-') parts = [] for piece in main.split('.'): if piece.isdigit(): parts.append(int(piece)) else: m = re.match(r'(\d+)', piece or '') parts.append(int(m.group(1)) if m else 0) while len(parts) < 3: parts.append(0) pre_rank = 0 if not pre else 1 return (parts[0], parts[1], parts[2], pre_rank, pre) def version_gt(a: str, b: str) -> bool: return parse_version(a) > parse_version(b) def normalize_tag(value: str) -> str: tag = (value or '').strip() if not tag: return '' return tag if tag.startswith('v') else f'v{tag}' def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]: try: proc = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding='utf-8', errors='replace', ) return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip() except FileNotFoundError: return 127, '', f'Command not found: {cmd[0]}' except subprocess.TimeoutExpired: return 124, '', f'Timeout running: {" ".join(cmd)}' class UpdateManager: def __init__(self, app_root: str, current_version: str): self.app_root = os.path.abspath(app_root) self.current_version = current_version def _in_docker(self) -> bool: return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' def _docker_update_allowed(self) -> bool: if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'): return False # In-container archive updates are allowed by default when /app is writable. return True def _app_root_writable(self) -> bool: probe = os.path.join(self.app_root, '.panel_update_write_probe') try: with open(probe, 'w', encoding='utf-8') as fh: fh.write('ok') os.remove(probe) return True except OSError: return False def detect_mode(self) -> dict: frozen = bool(getattr(sys, 'frozen', False)) in_docker = self._in_docker() git_dir = os.path.join(self.app_root, '.git') is_git = os.path.isdir(git_dir) git_ok = False if is_git and not frozen: code, _, _ = _run(['git', '--version'], self.app_root, timeout=10) git_ok = code == 0 writable = self._app_root_writable() can_git = bool(git_ok and is_git and not frozen) can_archive = bool( writable and not frozen and (not in_docker or self._docker_update_allowed()) ) can_auto = can_git or can_archive if can_git: method = 'git' elif can_archive: method = 'archive' elif in_docker: method = 'docker' elif frozen: method = 'binary' else: method = 'manual' return { 'mode': method, 'update_method': method, 'can_auto_update': can_auto, 'can_git_update': can_git, 'can_archive_update': can_archive, 'is_git': is_git, 'frozen': frozen, 'in_docker': in_docker, 'writable': writable, 'app_root': self.app_root, 'repo_url': repo_url(), 'git_url': git_url(), } def _ensure_update_remote(self) -> Optional[str]: code, out, err = _run(['git', 'remote'], self.app_root, timeout=15) if code != 0: return err or out or 'git remote failed' remotes = set((out or '').split()) target = git_url() if UPDATE_REMOTE in remotes: code, _, err = _run( ['git', 'remote', 'set-url', UPDATE_REMOTE, target], self.app_root, timeout=15, ) else: code, _, err = _run( ['git', 'remote', 'add', UPDATE_REMOTE, target], self.app_root, timeout=15, ) if code != 0: return err or 'Failed to configure update remote' return None def working_tree_dirty(self) -> bool: code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30) return code == 0 and bool(out.strip()) def _pip_install(self) -> tuple[bool, str]: req = os.path.join(self.app_root, 'requirements.txt') if not os.path.isfile(req): return True, '' code_p, out_p, err_p = _run( [sys.executable, '-m', 'pip', 'install', '-r', req], self.app_root, timeout=300, ) log = (out_p or err_p or '')[:1500] return code_p == 0, log def _apply_git_update(self, target: str, allow_dirty: bool) -> dict: if not allow_dirty and self.working_tree_dirty(): return { 'status': 'error', 'message': ( 'Working tree has local changes. Confirm update anyway to force checkout, ' 'or commit/stash changes first.' ), 'needs_confirm_dirty': True, } err = self._ensure_update_remote() if err: return {'status': 'error', 'message': err} code, out, stderr = _run( ['git', 'fetch', '--tags', '--force', UPDATE_REMOTE], self.app_root, timeout=180, ) if code != 0: return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'} # Ensure tag is present locally code, _, _ = _run( ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'], self.app_root, timeout=15, ) if code != 0: _run( ['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'], self.app_root, timeout=120, ) checkout_ref = f'tags/{target}' code, out, stderr = _run( ['git', 'checkout', '--force', checkout_ref], self.app_root, timeout=60, ) if code != 0: code_m, out_m, err_m = _run( ['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'], self.app_root, timeout=60, ) if code_m != 0: return { 'status': 'error', 'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}', } checkout_ref = f'{UPDATE_REMOTE}/main' ok, pip_log = self._pip_install() if not ok: return { 'status': 'error', 'message': f'pip install failed after checkout: {pip_log[:500]}', 'checkout': checkout_ref, 'method': 'git', } return { 'status': 'success', 'message': f'Updated to {target} via git. Panel will restart.', 'target_version': target, 'checkout': checkout_ref, 'pip': pip_log[:500], 'method': 'git', 'restart': True, } def _archive_urls(self, tag: str) -> list[str]: urls: list[str] = [] api_base = api_latest_url().rsplit('/releases/latest', 1)[0] for endpoint in (f'{api_base}/releases/tags/{tag}', api_latest_url()): try: req = urllib.request.Request( endpoint, headers={'Accept': 'application/json', 'User-Agent': 'Amnezia-Web-Panel-Updater'}, ) with urllib.request.urlopen(req, timeout=30) as resp: payload = json.loads(resp.read().decode('utf-8', errors='replace') or '{}') zipball = (payload.get('zipball_url') or '').strip() if zipball: urls.append(zipball) except Exception: pass base = repo_url() urls.extend([ f'{base}/archive/{tag}.zip', f'{api_base}/archive/{tag}.zip', ]) seen = set() ordered: list[str] = [] for url in urls: if url and url not in seen: seen.add(url) ordered.append(url) return ordered def _download_archive(self, tag: str, dest_path: str) -> None: last_err = 'unknown error' for url in self._archive_urls(tag): try: req = urllib.request.Request(url, headers={'User-Agent': 'Amnezia-Web-Panel-Updater'}) with urllib.request.urlopen(req, timeout=180) as resp: with open(dest_path, 'wb') as out: shutil.copyfileobj(resp, out) if os.path.getsize(dest_path) > 1024: return last_err = f'empty archive from {url}' except urllib.error.HTTPError as e: last_err = f'HTTP {e.code} for {url}' except Exception as e: last_err = str(e) raise RuntimeError(f'Failed to download release archive: {last_err}') @staticmethod def _extract_zip_root(extract_dir: str) -> str: entries = [e for e in os.listdir(extract_dir) if e not in ('.', '..')] if len(entries) == 1: only = os.path.join(extract_dir, entries[0]) if os.path.isdir(only): return only return extract_dir def _verify_tree(self, root: str) -> Optional[str]: app_py = os.path.join(root, 'app.py') if not os.path.isfile(app_py): return 'Release archive is missing app.py' code, _, err = _run([sys.executable, '-m', 'py_compile', app_py], root, timeout=60) if code != 0: return f'Updated app.py failed validation: {err or code}' return None def _sync_tree(self, src_root: str) -> None: for root, dirs, files in os.walk(src_root): dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS] rel = os.path.relpath(root, src_root) dest_root = self.app_root if rel in ('.', '') else os.path.join(self.app_root, rel) os.makedirs(dest_root, exist_ok=True) for fname in files: if fname in ARCHIVE_SKIP_FILES: continue src_file = os.path.join(root, fname) dest_file = os.path.join(dest_root, fname) shutil.copy2(src_file, dest_file) def _apply_archive_update(self, target: str) -> dict: tmpdir = tempfile.mkdtemp(prefix='panel-update-') zip_path = os.path.join(tmpdir, f'{target}.zip') extract_dir = os.path.join(tmpdir, 'extract') try: self._download_archive(target, zip_path) os.makedirs(extract_dir, exist_ok=True) with zipfile.ZipFile(zip_path, 'r') as zf: zf.extractall(extract_dir) src_root = self._extract_zip_root(extract_dir) if not os.path.isdir(src_root): return {'status': 'error', 'message': 'Invalid release archive layout'} verify_err = self._verify_tree(src_root) if verify_err: return {'status': 'error', 'message': verify_err, 'method': 'archive'} self._sync_tree(src_root) except Exception as e: return {'status': 'error', 'message': str(e), 'method': 'archive'} finally: shutil.rmtree(tmpdir, ignore_errors=True) ok, pip_log = self._pip_install() if not ok: return { 'status': 'error', 'message': f'Files updated but pip install failed: {pip_log[:500]}', 'method': 'archive', } return { 'status': 'success', 'message': f'Updated to {target} from release archive. Panel will restart.', 'target_version': target, 'pip': pip_log[:500], 'method': 'archive', 'restart': True, } def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict: mode = self.detect_mode() target = normalize_tag(target_version) if not target or not re.match(r'^v\d+(\.\d+){0,3}([.-][\w.]+)?$', target): return {'status': 'error', 'message': f'Invalid target version: {target_version}'} if mode.get('can_git_update'): return self._apply_git_update(target, allow_dirty) if mode.get('can_archive_update'): return self._apply_archive_update(target) hint = ( 'Cannot update from inside this install. ' 'Use a git clone or writable source directory, or rebuild the Docker image on the host.' ) if mode.get('in_docker'): hint = ( 'Docker image install: on the host run ' '`git pull && docker compose up -d --build` ' '(or set PANEL_UPDATE_ALLOW_DOCKER=1 if /app is bind-mounted).' ) return {'status': 'error', 'message': hint, 'mode': mode} @staticmethod def schedule_restart(app_root: str, delay_sec: float = 1.5) -> None: in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' def _restart(): time.sleep(max(0.5, float(delay_sec))) cwd = os.path.abspath(app_root or os.getcwd()) argv = [sys.executable] + sys.argv logger.info('Restarting panel after update: %s (cwd=%s, docker=%s)', argv, cwd, in_docker) if in_docker: # Let Docker restart policy bring the container back with updated /app files. os._exit(0) try: os.chdir(cwd) proc = subprocess.Popen( argv, cwd=cwd, start_new_session=True, close_fds=(os.name != 'nt'), ) time.sleep(2.0) if proc.poll() is None: logger.info('New panel process started (pid=%s), stopping old process', proc.pid) os._exit(0) except Exception: logger.exception('subprocess restart failed; trying execv') try: os.chdir(cwd) os.execv(sys.executable, argv) except Exception: logger.exception('Failed to restart after update') os._exit(1) threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()