From ff7cc5c592cf4246906218071665cb54ca9f46ae Mon Sep 17 00:00:00 2001 From: orohi Date: Tue, 28 Jul 2026 11:00:34 +0300 Subject: [PATCH] v2.5.6: fix 404 after update without tunnel --- Dockerfile | 1 + README.md | 3 +++ app.py | 51 +++++++++++++++++++++++++++++++++++--- managers/update_manager.py | 48 +++++++++++++++++++++++++---------- templates/settings.html | 16 ++++++------ 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index baa16c9..d1371ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ APP_PORT=5000 \ + PANEL_IN_DOCKER=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app diff --git a/README.md b/README.md index fe8c439..348af36 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.5.6 +* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link. + ### v2.5.5 * **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs. diff --git a/app.py b/app.py index 17e501c..7bacade 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre from starlette.background import BackgroundTask from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi import FastAPI, Request, Query, UploadFile, File +from fastapi import FastAPI, Request, Query, UploadFile, File, HTTPException +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware from pydantic import BaseModel from typing import Optional, List, Dict @@ -102,7 +103,7 @@ 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.5.5" +CURRENT_VERSION = "v2.5.6" 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')) @@ -278,6 +279,48 @@ def get_panel_tunnel_target_url(): return f"{scheme}://127.0.0.1:{port}" +def get_panel_public_url(request: Optional[Request] = None) -> str: + if request is not None: + forwarded_proto = (request.headers.get('x-forwarded-proto') or '').split(',')[0].strip() + scheme = forwarded_proto or request.url.scheme or 'http' + host = ( + (request.headers.get('x-forwarded-host') or '').split(',')[0].strip() + or request.headers.get('host') + or request.url.netloc + ) + if host: + return f"{scheme}://{host}/" + return get_panel_local_url(request).rstrip('/') + '/' + + +def enrich_update_result(request: Request, result: dict) -> dict: + result['panel_url'] = get_panel_public_url(request) + return result + + +@app.exception_handler(StarletteHTTPException) +async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code != 404: + detail = exc.detail if isinstance(exc.detail, str) else 'Error' + if request.url.path.startswith('/api/'): + return JSONResponse({'error': detail}, status_code=exc.status_code) + return HTMLResponse(f'

{exc.status_code}

{detail}

', status_code=exc.status_code) + if request.url.path.startswith('/api/') or 'application/json' in (request.headers.get('accept') or '').lower(): + return JSONResponse({'error': 'Not found'}, status_code=404) + home = get_panel_public_url(request) + return HTMLResponse( + f'''Amnezia Panel + +

Страница не найдена

+

Адрес {request.url.path} не существует в панели.

+

Если вы только что обновили панель, подождите 10–30 секунд и откройте:

+

{home}

+

/health — проверка, что панель запущена.

+ ''', + status_code=404, + ) + + @app.get('/health', include_in_schema=False) @app.get('/api/health', include_in_schema=False) async def health_check(): @@ -6009,7 +6052,7 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest): if result.get('restart'): UpdateManager.schedule_restart(application_path, 1.5) - return result + return enrich_update_result(request, result) @app.post('/api/settings/upgrade_panel', tags=["Settings"]) @@ -6049,7 +6092,7 @@ async def api_upgrade_panel(request: Request): UpdateManager.schedule_restart(application_path, 1.5) result['up_to_date'] = False result['previous_version'] = CURRENT_VERSION - return result + return enrich_update_result(request, result) @app.get('/api/settings/tunnels/status', tags=["Settings"]) diff --git a/managers/update_manager.py b/managers/update_manager.py index efd4878..c97ac1d 100644 --- a/managers/update_manager.py +++ b/managers/update_manager.py @@ -333,6 +333,15 @@ class UpdateManager: 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] @@ -358,6 +367,9 @@ class UpdateManager: 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'} @@ -406,25 +418,35 @@ class UpdateManager: @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))) - argv = [sys.executable] + sys.argv cwd = os.path.abspath(app_root or os.getcwd()) - logger.info('Restarting panel after update: %s (cwd=%s)', argv, cwd) + 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('execv restart failed; trying subprocess fallback') - try: - subprocess.Popen( - argv, - cwd=cwd, - close_fds=True, - start_new_session=True, - ) - except Exception: - logger.exception('Failed to restart after update') - os._exit(0) + logger.exception('Failed to restart after update') + os._exit(1) threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start() diff --git a/templates/settings.html b/templates/settings.html index e27a366..f7ff06c 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1718,19 +1718,21 @@ } } - async function waitForPanelRestart(timeoutMs = 90000) { + async function waitForPanelRestart(timeoutMs = 120000, panelUrl) { + const base = String(panelUrl || window.location.origin || '').replace(/\/$/, ''); const started = Date.now(); while (Date.now() - started < timeoutMs) { try { - const res = await fetch('/api/health', { cache: 'no-store' }); + const res = await fetch(`${base}/api/health`, { cache: 'no-store' }); if (res.ok) { - window.location.reload(); + window.location.href = base + '/'; return true; } } catch (_) {} - await new Promise((resolve) => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 2000)); } - showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}", 'error'); + const hint = base ? ` ${base}` : ''; + showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error'); return false; } @@ -1758,7 +1760,7 @@ showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success'); restarting = true; applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}"; - await waitForPanelRestart(); + await waitForPanelRestart(120000, data.panel_url); } catch (err) { showToast(_('error') + ': ' + err.message, 'error'); } finally { @@ -1794,7 +1796,7 @@ showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success'); restarting = true; text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}"; - await waitForPanelRestart(); + await waitForPanelRestart(120000, data.panel_url); } catch (err) { showToast(_('error') + ': ' + err.message, 'error'); await checkForUpdates(true);