Compare commits

...
8 Commits
15 changed files with 516 additions and 104 deletions
+10 -3
View File
@@ -2,11 +2,18 @@
APP_PORT=5000
SECRET_KEY=change-me-to-a-long-random-string
# PostgreSQL (used by docker-compose)
# PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
POSTGRES_USER=amnezia
POSTGRES_PASSWORD=amnezia
POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=amnezia_panel
POSTGRES_PORT=5432
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
# Full DSN (override if panel runs outside compose)
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel
# DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
# Dokploy / reverse proxy (optional — set in compose for Docker)
# PANEL_IN_DOCKER=1
# PANEL_BEHIND_PROXY=1
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+7 -4
View File
@@ -5,12 +5,15 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_PORT=5000 \
PORT=5000 \
PANEL_IN_DOCKER=1 \
PANEL_BEHIND_PROXY=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get install -y --no-install-recommends curl postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
@@ -20,7 +23,7 @@ COPY . .
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -fsS "http://127.0.0.1:5000/health" >/dev/null || exit 1
CMD ["python3", "app.py"]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--proxy-headers", "--forwarded-allow-ips", "*"]
+84 -4
View File
@@ -188,22 +188,71 @@ Mac
```bash
cp .env.example .env
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create dokploy-network
docker compose up -d --build
```
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
### Dokploy (recommended)
The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80).
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
2. In Dokploy → your app → **Environment** (set **before first deploy**):
- `SECRET_KEY` — long random string
- `POSTGRES_PASSWORD` — strong password (same value is used for `db` and `amnezia_panel`)
3. In Dokploy → **Domains**:
- **Service**: `amnezia_panel` (not `db`)
- **Container port**: `5000`
- **Path**: `/`
4. **Deploy / Rebuild** (not Restart) after code or env changes.
**Verify on the server** (replace `PROJECT` and path with yours):
```bash
COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
PROJECT=home-vpn-g6rfpd
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml exec amnezia_panel \
curl -fsS http://127.0.0.1:5000/health
# → {"status":"ok","version":"v2.6.0"}
docker inspect ${PROJECT}-amnezia_panel-1 \
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
# must include: dokploy-network
```
**Full redeploy after git update:**
```bash
cd $COMPOSE_DIR && git pull origin main
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml build --no-cache amnezia_panel
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --force-recreate
```
**Postgres `password authentication failed`:** the volume keeps the password from the **first** deploy. If you change `POSTGRES_PASSWORD` in Dokploy later, reset the DB volume (wipes panel DB data):
```bash
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
docker volume rm ${PROJECT}_amnezia_pgdata
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --build
```
**Domain shows `404 page not found` (plain text):** Traefik has no route to the panel — check Domains (service `amnezia_panel`, port `5000`) and that the container is on `dokploy-network` (see verify commands above).
| File | Purpose |
| --- | --- |
| `Dockerfile` | Production image (Python 3.12) |
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks |
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks, Dokploy/Traefik labels |
| `.env.example` | Environment template |
**Environment**
| Variable | Default | Description |
| --- | --- | --- |
| `APP_PORT` | `5000` | Host port / in-container listen port |
| `APP_PORT` / `PORT` | `5000` | Host port / in-container listen port |
| `SECRET_KEY` | (random) | Session signing key — set in production |
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
@@ -228,8 +277,39 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### v2.5.4
* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links).
### v2.6.2
* **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link.
### v2.6.1
* **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact.
### v2.6.0 — stable Dokploy / Docker release
* **Dokploy-ready compose** — `dokploy-network`, Traefik labels, port **5000**, no fixed `container_name`.
* **Docker startup** — `uvicorn` with proxy headers; SSL inside container disabled when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY`.
* **Postgres healthcheck** — verifies password (catches `POSTGRES_PASSWORD` vs volume mismatch).
* **Rollback move-connections** — removed server-to-server config move (v2.5.4); restored `ToggleConnectionRequest` (fixes crash loop on deploy).
* **Docs** — full Dokploy deploy/redeploy/password-reset guide in README.
### v2.5.11
* **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts).
### v2.5.10
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
### v2.5.9
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
### v2.5.8
* **Docker/Dokploy: panel actually serves HTTP** — entrypoint waits for Postgres, runs `uvicorn` with proxy headers; SSL inside the container is forced off when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY` is set (fixes empty replies on port 5000 behind Traefik).
### v2.5.7
* **Dokploy / Traefik 404 fix** — `docker-compose.yml` joins `dokploy-network`, Traefik labels point to port **5000**; removed fixed `container_name`; README Dokploy domain setup.
### 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.
### v2.5.3
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
+180 -46
View File
@@ -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.4"
CURRENT_VERSION = "v2.6.2"
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'))
@@ -110,11 +111,14 @@ TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application
# Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API.
from db import ( # noqa: E402
backup_filename,
clear_tunnel_state,
ensure_db_ready,
export_data_dict,
export_database_sql,
load_data,
load_tunnel_state,
restore_database_sql,
save_data,
save_tunnel_state,
update_tunnel_state,
@@ -224,12 +228,49 @@ def tpl(request, template, **kwargs):
return templates.TemplateResponse(template, ctx)
def get_panel_listen_port(data: Optional[dict] = None) -> int:
for env_key in ('APP_PORT', 'PORT'):
try:
env_port = int(os.environ.get(env_key, '0') or '0')
except ValueError:
env_port = 0
if env_port:
return env_port
if data is None:
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
try:
return int(ssl_conf.get('panel_port', 5000) or 5000)
except (TypeError, ValueError):
return 5000
def panel_ssl_active(data: Optional[dict] = None) -> bool:
# Behind Docker / reverse proxy TLS is always terminated outside the app.
if os.environ.get('PANEL_IN_DOCKER') == '1' or os.environ.get('PANEL_BEHIND_PROXY') == '1':
return False
if os.environ.get('APP_PORT') or os.environ.get('PORT'):
return False
if data is None:
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
if not ssl_conf.get('enabled'):
return False
cert_path = ssl_conf.get('cert_path')
key_path = ssl_conf.get('key_path')
if ssl_conf.get('cert_text') or ssl_conf.get('key_text'):
return True
return bool(
cert_path and key_path
and os.path.exists(cert_path) and os.path.exists(key_path)
)
def get_panel_local_url(request: Optional[Request] = None):
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
scheme = 'https' if panel_ssl_active(data) else 'http'
host = '127.0.0.1'
port = ssl_conf.get('panel_port', 5000) or 5000
port = get_panel_listen_port(data)
if request:
scheme = request.url.scheme or scheme
host = request.url.hostname or host
@@ -239,12 +280,59 @@ def get_panel_local_url(request: Optional[Request] = None):
def get_panel_tunnel_target_url():
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
port = ssl_conf.get('panel_port', 5000) or 5000
scheme = 'https' if panel_ssl_active(data) else 'http'
port = get_panel_listen_port(data)
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'<h1>{exc.status_code}</h1><p>{detail}</p>', 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'''<!DOCTYPE html><html><head><meta charset="utf-8"><title>Amnezia Panel</title></head>
<body style="font-family:sans-serif;max-width:520px;margin:3rem auto;padding:0 1rem;">
<h1>Страница не найдена</h1>
<p>Адрес <code>{request.url.path}</code> не существует в панели.</p>
<p>Если вы только что обновили панель, подождите 1030 секунд и откройте:</p>
<p><a href="{home}">{home}</a></p>
<p><a href="/health">/health</a> проверка, что панель запущена.</p>
</body></html>''',
status_code=404,
)
@app.get('/health', include_in_schema=False)
@app.get('/api/health', include_in_schema=False)
async def health_check():
return {'status': 'ok', 'version': CURRENT_VERSION}
def get_warp_cli_binary():
found = shutil.which(WARP_CLI_COMMAND)
if found:
@@ -2109,6 +2197,12 @@ class ConnectionActionRequest(BaseModel):
client_id: str = ''
class ToggleConnectionRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
enable: bool = True
class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg'
target_server_id: int
@@ -2436,6 +2530,24 @@ async def startup():
logger.info("Starting Telegram bot from saved settings...")
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link, save_data)
# Reattach quick tunnels after panel restart so public URLs keep working.
try:
tunnel_state = await asyncio.to_thread(load_tunnel_state)
local_url = get_panel_tunnel_target_url()
for provider in ('cloudflare', 'ngrok'):
state = tunnel_state.get(provider) or {}
if not (state.get('public_url') or state.get('pid') or state.get('started_at')):
continue
if state.get('pid') and pid_is_running(state.get('pid')):
continue
try:
await asyncio.to_thread(start_tunnel, provider, local_url)
logger.info('Restarted %s tunnel after panel boot -> %s', provider, local_url)
except Exception as exc:
logger.warning('Could not restart %s tunnel after panel boot: %s', provider, exc)
except Exception as exc:
logger.warning('Tunnel auto-restart skipped: %s', exc)
def _scrape_server_traffic(server, sid, my_conns):
server_updates = []
@@ -5843,11 +5955,11 @@ async def api_my_connection_config(request: Request, connection_id: str):
return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/settings', tags=["System Templates"])
@app.get('/settings', response_class=HTMLResponse, tags=["System Templates"])
async def settings_page(request: Request):
user = _check_admin(request)
if not user:
return RedirectResponse('/login')
return RedirectResponse('/login', status_code=302)
data = load_data()
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
settings = data.setdefault('settings', {})
@@ -5951,8 +6063,8 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
if result.get('restart'):
UpdateManager.schedule_restart(1.5)
return result
UpdateManager.schedule_restart(application_path, 1.5)
return enrich_update_result(request, result)
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
@@ -5989,10 +6101,10 @@ async def api_upgrade_panel(request: Request):
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)
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"])
@@ -6482,6 +6594,24 @@ async def api_revoke_token(request: Request, token_id: str):
@app.get('/api/settings/backup/download', tags=["Settings"])
async def api_backup_download(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
content = await asyncio.to_thread(export_database_sql)
except Exception as e:
logger.exception('Database backup failed')
return JSONResponse({'error': str(e)}, status_code=500)
filename = backup_filename()
return StreamingResponse(
io.BytesIO(content),
media_type='application/sql',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@app.get('/api/settings/backup/download/json', tags=["Settings"])
async def api_backup_download_json(request: Request):
"""Legacy JSON export (same shape as old data.json)."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
payload = await asyncio.to_thread(export_data_dict)
@@ -6501,33 +6631,39 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
content = await file.read()
if not content:
return JSONResponse({'error': 'Empty file'}, status_code=400)
filename = (file.filename or '').lower()
is_json = filename.endswith('.json') or content.lstrip().startswith(b'{')
if is_json:
try:
backup_data = json.loads(content)
except json.JSONDecodeError:
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
required_keys = ['servers', 'users']
missing = [k for k in required_keys if k not in backup_data]
if missing:
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
backup_data.setdefault('user_connections', [])
backup_data.setdefault('api_tokens', [])
backup_data.setdefault('invite_links', [])
backup_data.setdefault('settings', {})
async with DATA_LOCK:
save_data(backup_data)
return {'status': 'success', 'format': 'json'}
try:
backup_data = json.loads(content)
except json.JSONDecodeError:
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
# Basic structure validation
required_keys = ['servers', 'users']
missing = [k for k in required_keys if k not in backup_data]
if missing:
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
# Ensure types are correct
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
backup_data.setdefault('user_connections', [])
backup_data.setdefault('api_tokens', [])
backup_data.setdefault('invite_links', [])
backup_data.setdefault('settings', {})
# Save the new data
async with DATA_LOCK:
save_data(backup_data)
# In a real app we might want to restart or re-init background tasks
return {'status': 'success'}
await asyncio.to_thread(restore_database_sql, content)
except Exception as e:
logger.exception('Database restore failed')
return JSONResponse({'error': str(e)}, status_code=400)
return {'status': 'success', 'format': 'sql'}
except Exception as e:
logger.exception("Error during restore")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -6557,17 +6693,15 @@ if __name__ == '__main__':
with open(key_file, 'w') as f:
f.write(ssl_conf['key_text'].strip() + '\n')
try:
env_port = int(os.environ.get('APP_PORT', '0') or '0')
except ValueError:
env_port = 0
port = get_panel_listen_port(data)
uvicorn_kwargs = {
"app": app,
"host": "0.0.0.0",
"port": env_port or ssl_conf.get('panel_port', 5000) or 5000,
"port": port,
}
logger.info(f"Amnezia Web Panel {CURRENT_VERSION} listening on http://0.0.0.0:{port}")
if ssl_conf.get('enabled') and cert_file and key_file:
if panel_ssl_active(data) and cert_file and key_file:
if os.path.exists(cert_file) and os.path.exists(key_file):
logger.info(f"Starting panel with HTTPS enabled on domain: {ssl_conf.get('domain')} at port {uvicorn_kwargs['port']}")
uvicorn_kwargs["ssl_certfile"] = cert_file
+4
View File
@@ -1,5 +1,6 @@
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
from .backup import backup_filename, export_database_sql, restore_database_sql
from .connection import close_pool, get_database_url, init_schema
from .store import (
clear_tunnel_state,
@@ -14,15 +15,18 @@ from .store import (
)
__all__ = [
'backup_filename',
'clear_tunnel_state',
'close_pool',
'ensure_db_ready',
'export_data_dict',
'export_database_sql',
'get_database_url',
'import_from_json_file',
'init_schema',
'load_data',
'load_tunnel_state',
'restore_database_sql',
'save_data',
'save_tunnel_state',
'update_tunnel_state',
+59
View File
@@ -0,0 +1,59 @@
"""PostgreSQL backup/restore for the panel database."""
from __future__ import annotations
import logging
import subprocess
from datetime import datetime, timezone
from .connection import get_database_url
from .store import invalidate_data_cache
logger = logging.getLogger(__name__)
def backup_filename() -> str:
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
return f'amnezia_panel_backup_{stamp}.sql'
def export_database_sql() -> bytes:
"""Create a plain SQL dump of the panel PostgreSQL database."""
url = get_database_url()
proc = subprocess.run(
[
'pg_dump',
'--dbname', url,
'--no-owner',
'--no-acl',
'--clean',
'--if-exists',
],
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or 'pg_dump failed')
if not proc.stdout:
raise RuntimeError('pg_dump returned empty dump')
return proc.stdout
def restore_database_sql(data: bytes) -> None:
"""Restore panel data from a plain SQL dump produced by pg_dump."""
if not data or not data.strip():
raise ValueError('Empty backup file')
url = get_database_url()
proc = subprocess.run(
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
input=data,
capture_output=True,
check=False,
)
if proc.returncode != 0:
err = proc.stderr.decode('utf-8', errors='replace').strip()
out = proc.stdout.decode('utf-8', errors='replace').strip()
raise RuntimeError(err or out or 'psql restore failed')
invalidate_data_cache()
logger.info('PostgreSQL backup restored successfully')
+26 -7
View File
@@ -1,18 +1,21 @@
services:
db:
image: postgres:17
container_name: amnezia_panel_db
environment:
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
volumes:
- amnezia_pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
test:
[
"CMD-SHELL",
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
]
interval: 10s
timeout: 5s
retries: 10
@@ -23,7 +26,6 @@ services:
context: .
dockerfile: Dockerfile
image: amnezia-panel:local
container_name: amnezia_panel
depends_on:
db:
condition: service_healthy
@@ -33,15 +35,32 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000"
PORT: "5000"
PANEL_IN_DOCKER: "1"
PANEL_BEHIND_PROXY: "1"
volumes:
- amnezia_data:/app/data
restart: unless-stopped
networks:
- internal
- dokploy-network
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
expose:
- "5000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
start_period: 60s
networks:
internal:
dokploy-network:
external: true
name: dokploy-network
volumes:
amnezia_data:
+64 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
import re
@@ -278,12 +279,33 @@ class UpdateManager:
}
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()
return [
urls.extend([
f'{base}/archive/{tag}.zip',
f'{base}/archive/{tag.lstrip("v")}.zip',
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/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'
@@ -311,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]
@@ -336,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'}
@@ -383,15 +417,36 @@ class UpdateManager:
return {'status': 'error', 'message': hint, 'mode': mode}
@staticmethod
def schedule_restart(delay_sec: float = 1.5) -> None:
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:
argv = [sys.executable] + sys.argv
logger.info('Restarting panel after update: %s', argv)
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; exiting')
os._exit(0)
logger.exception('Failed to restart after update')
os._exit(1)
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
+47 -11
View File
@@ -604,15 +604,19 @@
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="display: flex; gap: var(--space-sm);">
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
<a href="/api/settings/backup/download" class="btn btn-secondary"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>⬇️</span> {{ _('download_backup') }}
</a>
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
<span>📄</span> {{ _('download_backup_json') }}
</a>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
<input type="file" id="backupFile" accept=".json" style="display: none;"
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
onchange="handleRestore(event)">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
@@ -623,6 +627,9 @@
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('backup_hint') }}
</p>
<p class="form-hint" style="margin-top: var(--space-xs);">
{{ _('restore_confirm') }}
</p>
</div>
@@ -1718,6 +1725,24 @@
}
}
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(`${base}/api/health`, { cache: 'no-store' });
if (res.ok) {
window.location.href = base + '/';
return true;
}
} catch (_) {}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const hint = base ? ` ${base}` : '';
showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error');
return false;
}
async function applyPanelUpdate() {
const applyBtn = document.getElementById('applyUpdateBtn');
const applyText = document.getElementById('applyUpdateBtnText');
@@ -1733,18 +1758,24 @@
applyBtn.disabled = true;
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
applySpinner.classList.remove('hidden');
let restarting = false;
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: true,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
restarting = true;
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(120000, data.panel_url);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
} finally {
if (!restarting) {
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
}
}
}
@@ -1761,6 +1792,7 @@
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
let restarting = false;
try {
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
if (data.up_to_date) {
@@ -1769,14 +1801,18 @@
return;
}
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 4000);
restarting = true;
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(120000, data.panel_url);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
await checkForUpdates(true);
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
if (!restarting) {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
}
+8 -4
View File
@@ -361,11 +361,13 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Simple Backup",
"download_backup": "Download data.json",
"restore_backup": "Restore from file",
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.",
"download_backup": "Download PostgreSQL dump (.sql)",
"download_backup_json": "Export JSON (legacy)",
"backup_hint": "Full PostgreSQL database dump of the panel. Use .sql for complete backup; JSON export is compatible with older versions.",
"restore_backup": "Restore from .sql or .json",
"restore_confirm": "Restore will overwrite all current panel data in the database.",
"restore_success": "Restore successful! Restarting...",
"invalid_backup_file": "Invalid backup file or structure",
"invalid_backup_file": "Invalid backup file (.sql dump or legacy data.json)",
"config_unavailable": "Configuration unavailable",
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user\u0027s device and cannot be recovered by the server.",
"client_public_key": "Client public key:",
@@ -505,6 +507,8 @@
"applying_update": "Updating…",
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
"update_restarting": "Updated. Restarting panel…",
"update_waiting_restart": "Waiting for panel to restart…",
"update_restart_timeout": "Panel did not come back in time. Refresh the page manually.",
"update_no_target": "No target version. Check for updates first.",
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.",
+6 -4
View File
@@ -344,11 +344,13 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "پشتیبان‌گیری ساده",
"download_backup": "دانلود data.json",
"restore_backup": "بازیابی از فایل",
"restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.",
"download_backup": "دانلود dump PostgreSQL (.sql)",
"download_backup_json": "خروجی JSON (قدیمی)",
"backup_hint": "Dump کامل پایگاه داده PostgreSQL پنل. برای پشتیبان کامل از .sql استفاده کنید؛ JSON برای نسخه‌های قدیمی.",
"restore_backup": "بازیابی از .sql یا .json",
"restore_confirm": "بازیابی تمام داده‌های فعلی پنل در پایگاه داده را بازنویسی می‌کند.",
"restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...",
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است",
"invalid_backup_file": "فایل نامعتبر (dump .sql یا data.json قدیمی)",
"config_unavailable": "پیکربندی در دسترس نیست",
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.",
"client_public_key": "کلید عمومی کلاینت:",
+6 -4
View File
@@ -344,11 +344,13 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Sauvegarde Simple",
"download_backup": "Télécharger data.json",
"restore_backup": "Restaurer depuis un fichier",
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l\u0027application redémarrera.",
"download_backup": "Télécharger le dump PostgreSQL (.sql)",
"download_backup_json": "Exporter JSON (ancien)",
"backup_hint": "Dump complet de la base PostgreSQL du panneau. Utilisez .sql pour une sauvegarde complète ; JSON pour l'ancien format.",
"restore_backup": "Restaurer depuis .sql ou .json",
"restore_confirm": "La restauration écrasera toutes les données actuelles du panneau dans la base.",
"restore_success": "Restauration réussie ! Redémarrage...",
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide",
"invalid_backup_file": "Fichier invalide (dump .sql ou ancien data.json)",
"config_unavailable": "Configuration indisponible",
"config_unavailable_desc": "Ce client a été créé via l\u0027application native Amnezia.\\nLa clé privée est stockée uniquement sur l\u0027appareil de l\u0027utilisateur et ne peut pas être récupérée par le serveur.",
"client_public_key": "Clé publique du client :",
+8 -4
View File
@@ -361,11 +361,13 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "Резервное копирование",
"download_backup": "Скачать data.json",
"restore_backup": "Восстановить из файла",
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.",
"download_backup": "Скачать дамп PostgreSQL (.sql)",
"download_backup_json": "Экспорт JSON (устар.)",
"backup_hint": "Полный дамп базы данных панели. Для бэкапа используйте .sql; JSON — для совместимости со старыми версиями.",
"restore_backup": "Восстановить из .sql или .json",
"restore_confirm": "Восстановление перезапишет все текущие данные панели в базе.",
"restore_success": "Восстановление успешно! Перезагрузка...",
"invalid_backup_file": "Неверный файл резервной копии или структура",
"invalid_backup_file": "Неверный файл (.sql дамп или устаревший data.json)",
"config_unavailable": "Конфигурация недоступна",
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
"client_public_key": "Публичный ключ клиента:",
@@ -505,6 +507,8 @@
"applying_update": "Обновление…",
"apply_update_confirm": "Установить обновление с git.evilfox.cc и перезапустить панель?",
"update_restarting": "Обновлено. Перезапуск панели…",
"update_waiting_restart": "Ожидание перезапуска панели…",
"update_restart_timeout": "Панель не ответила вовремя. Обновите страницу вручную.",
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
"auto_update_ready": "Для этой установки доступно автообновление через git.",
+6 -4
View File
@@ -344,11 +344,13 @@
"lang_zh": "中文 (Chinese)",
"lang_fa": "فارسی (Persian)",
"backup_title": "简易备份",
"download_backup": "下载 data.json",
"restore_backup": "从文件恢复",
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
"download_backup": "下载 PostgreSQL 转储 (.sql)",
"download_backup_json": "导出 JSON(旧版)",
"backup_hint": "面板 PostgreSQL 数据库的完整转储。请使用 .sql 进行完整备份;JSON 用于兼容旧版本。",
"restore_backup": "从 .sql 或 .json 恢复",
"restore_confirm": "恢复将覆盖数据库中所有当前面板数据。",
"restore_success": "恢复成功!正在重启...",
"invalid_backup_file": "备份文件无效或结构错误",
"invalid_backup_file": "无效的备份文件.sql 转储或旧版 data.json",
"config_unavailable": "配置文件不可用",
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
"client_public_key": "客户端公钥:",