diff --git a/README.md b/README.md index 10d3241..7d522c5 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Configuration panel for system parameters and preferences: * **🌐 Internationalization (i18n)**: * Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**. * Native **RTL (Right-to-Left)** support for Persian language. +* **🔄 Auto-update**: + * Settings → About checks releases on [git.evilfox.cc](https://git.evilfox.cc/test2/Amnezia-Web-Panel-main) and can install the latest tag via git + restart (when the panel runs from a git checkout). * **👥 Advanced User Management**: * Role-based access (Admin, Support, Regular User). * Traffic limits, status monitoring, and account expiration. @@ -222,6 +224,9 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.4.0 +* **Auto-update from Gitea**: Settings → About checks https://git.evilfox.cc/test2/Amnezia-Web-Panel-main releases and can install the latest tag via `git fetch` + checkout, then restart (git install only). + ### v2.3.0 * **Cascade removed** — double-VPN feature dropped from the panel (manager, API, UI, i18n). diff --git a/app.py b/app.py index 4ad4c0d..03ca7cb 100644 --- a/app.py +++ b/app.py @@ -46,6 +46,7 @@ from managers.awg_manager import AWGManager from managers.xray_manager import XrayManager from managers.wireguard_manager import WireGuardManager from managers.backup_manager import BackupManager +from managers.update_manager import UpdateManager, version_gt, api_latest_url, repo_url import telegram_bot as tg_bot # Configure logging @@ -101,9 +102,9 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.3.0" -RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main" -RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest" +CURRENT_VERSION = "v2.4.0" +RELEASES_REPO_URL = repo_url() +RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json')) @@ -1992,6 +1993,11 @@ class TunnelStartRequest(BaseModel): authtoken: Optional[str] = None +class ApplyUpdateRequest(BaseModel): + target_version: Optional[str] = None + allow_dirty: bool = False + + # ======================== Startup ======================== @app.on_event("startup") @@ -5498,6 +5504,8 @@ async def api_check_updates(request: Request): if not _check_admin(request): return JSONResponse({'error': 'Forbidden'}, status_code=403) try: + updater = UpdateManager(application_path, CURRENT_VERSION) + mode = updater.detect_mode() async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: resp = await client.get( RELEASES_API_LATEST, @@ -5511,20 +5519,61 @@ async def api_check_updates(request: Request): data = resp.json() if resp.content else {} latest = (data.get('tag_name') or data.get('name') or '').strip() html_url = (data.get('html_url') or f'{RELEASES_REPO_URL}/releases').strip() + body = (data.get('body') or '')[:4000] current = CURRENT_VERSION - update_available = bool(latest) and latest != current + update_available = bool(latest) and version_gt(latest, current) return { 'current_version': current, 'latest_version': latest, 'update_available': update_available, 'html_url': html_url, 'releases_url': f'{RELEASES_REPO_URL}/releases', + 'body': body, + 'can_auto_update': bool(mode.get('can_auto_update')), + 'update_mode': mode.get('mode'), + 'mode': mode, } except Exception as e: logger.exception('Error checking for updates') return JSONResponse({'error': str(e)}, status_code=502) +@app.post('/api/settings/apply_update', tags=["Settings"]) +async def api_apply_update(request: Request, req: ApplyUpdateRequest): + """Pull the selected release tag from git.evilfox.cc and restart the panel.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + updater = UpdateManager(application_path, CURRENT_VERSION) + target = (req.target_version or '').strip() + if not target: + # Resolve latest if UI did not pass a tag + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + resp = await client.get( + RELEASES_API_LATEST, + headers={'Accept': 'application/json'}, + ) + if resp.status_code < 400 and resp.content: + payload = resp.json() + target = (payload.get('tag_name') or payload.get('name') or '').strip() + except Exception: + logger.exception('Failed to resolve latest tag for apply_update') + if not target: + return JSONResponse({'error': 'No target version specified'}, status_code=400) + + result = await asyncio.to_thread( + updater.apply_update, + target, + bool(req.allow_dirty), + ) + if result.get('status') != 'success': + return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500) + + if result.get('restart'): + UpdateManager.schedule_restart(1.5) + return result + + @app.get('/api/settings/tunnels/status', tags=["Settings"]) async def api_tunnels_status(request: Request): if not _check_admin(request): diff --git a/managers/update_manager.py b/managers/update_manager.py new file mode 100644 index 0000000..7427e30 --- /dev/null +++ b/managers/update_manager.py @@ -0,0 +1,249 @@ +"""Panel self-update from the EvilFox Gitea repository.""" + +from __future__ import annotations + +import logging +import os +import re +import subprocess +import sys +import threading +import time +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' + + +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) + # release > prerelease: empty pre sorts higher + 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 _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 detect_mode(self) -> dict: + frozen = bool(getattr(sys, 'frozen', False)) + in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' + 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 + can_auto = bool(git_ok and not frozen) + mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual')) + return { + 'mode': mode, + 'can_auto_update': can_auto, + 'is_git': is_git, + 'frozen': frozen, + 'in_docker': in_docker, + '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 apply_update(self, target_version: str, allow_dirty: bool = False) -> dict: + mode = self.detect_mode() + if not mode['can_auto_update']: + return { + 'status': 'error', + 'message': ( + 'Auto-update requires a git checkout of the panel sources. ' + 'Docker/binary installs: pull a new image or download a release manually.' + ), + 'mode': mode, + } + + target = (target_version or '').strip() + if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target): + return {'status': 'error', 'message': f'Invalid target version: {target_version}'} + if not target.startswith('v'): + target = 'v' + target + + if not allow_dirty and self.working_tree_dirty(): + return { + 'status': 'error', + 'message': ( + 'Working tree has local changes. Commit/stash them or set allow_dirty, ' + 'then retry auto-update.' + ), + } + + 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}', + } + + # Prefer annotated/lightweight tag; fall back to remote main tip if tag missing + code, _, _ = _run( + ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'], + self.app_root, + timeout=15, + ) + if code == 0: + checkout_ref = target + else: + # tag might only exist as FETCH_HEAD / remote tag + code2, _, _ = _run( + ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'], + self.app_root, + timeout=15, + ) + checkout_ref = target if code2 == 0 else f'tags/{target}' + + code, out, stderr = _run( + ['git', 'checkout', '--force', checkout_ref], + self.app_root, + timeout=60, + ) + if code != 0: + # last resort: checkout remote main + 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' + + req = os.path.join(self.app_root, 'requirements.txt') + pip_log = '' + if os.path.isfile(req): + code_p, out_p, err_p = _run( + [sys.executable, '-m', 'pip', 'install', '-r', req], + self.app_root, + timeout=300, + ) + pip_log = (out_p or err_p or '')[:1500] + if code_p != 0: + return { + 'status': 'error', + 'message': f'pip install failed after checkout: {err_p or out_p or code_p}', + 'checkout': checkout_ref, + } + + return { + 'status': 'success', + 'message': f'Updated to {target}. Panel will restart.', + 'target_version': target, + 'checkout': checkout_ref, + 'pip': pip_log[:500], + 'restart': True, + } + + @staticmethod + def schedule_restart(delay_sec: float = 1.5) -> None: + def _restart(): + time.sleep(max(0.5, float(delay_sec))) + try: + argv = [sys.executable] + sys.argv + logger.info('Restarting panel after update: %s', argv) + os.execv(sys.executable, argv) + except Exception: + logger.exception('Failed to restart after update; exiting') + os._exit(0) + + threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start() diff --git a/templates/settings.html b/templates/settings.html index 909d926..aa8e2ee 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -597,15 +597,20 @@ -