diff --git a/Dockerfile b/Dockerfile index d1371ab..915049c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ 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 @@ -18,10 +20,11 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN chmod +x scripts/docker-entrypoint.sh EXPOSE 5000 -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -fsS "http://127.0.0.1:${APP_PORT}/health" >/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"] +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 348af36..b5be320 100644 --- a/README.md +++ b/README.md @@ -188,22 +188,48 @@ 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 + +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 → **Domains**: + - **Service**: `amnezia_panel` (not `db`) + - **Container port**: `5000` + - **Path**: `/` +3. Set env vars in Dokploy: `SECRET_KEY` (long random string), strong `POSTGRES_PASSWORD`. +4. Redeploy after changing domains or env. + +If the domain shows **404 page not found** (plain text, Go-style), Traefik has no route to a healthy backend. After redeploying **v2.5.8+**, on the server run: + +```bash +# 1) Panel must answer HTTP inside the container +docker compose -p home-vpn-g6rfpd exec amnezia_panel curl -fsS http://127.0.0.1:5000/health + +# 2) Panel container must be on dokploy-network (required for Traefik) +docker inspect "$(docker compose -p home-vpn-g6rfpd ps -q amnezia_panel)" \ + --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' +``` + +You should see `dokploy-network` in the network list. If it is missing, Dokploy cannot route the domain — redeploy with the latest `docker-compose.yml` or run `docker network connect dokploy-network ` and redeploy Traefik. + | 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,15 +254,21 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### 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.4 -* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links). - ### v2.5.3 * **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable. diff --git a/app.py b/app.py index 7bacade..4e04c53 100644 --- a/app.py +++ b/app.py @@ -103,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.6" +CURRENT_VERSION = "v2.5.9" 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')) @@ -226,12 +226,13 @@ def tpl(request, template, **kwargs): def get_panel_listen_port(data: Optional[dict] = None) -> int: - try: - env_port = int(os.environ.get('APP_PORT', '0') or '0') - except ValueError: - env_port = 0 - if env_port: - return env_port + 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 {} @@ -242,14 +243,16 @@ def get_panel_listen_port(data: Optional[dict] = None) -> int: 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 - # Docker/compose usually terminates TLS outside the app and serves plain HTTP inside. - if os.environ.get('APP_PORT'): - 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'): @@ -1393,186 +1396,6 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool: return True -def resolve_protocol_on_server(server: dict, protocol: str) -> Optional[str]: - """Pick an installed protocol key on server matching the requested protocol/base.""" - protocols = server.get('protocols') or {} - if protocol in protocols and protocols[protocol].get('installed'): - return protocol - base = protocol_base(protocol) - candidates = [ - key for key, info in protocols.items() - if protocol_base(key) == base and info.get('installed') - ] - if not candidates: - return None - return sorted(candidates, key=protocol_instance)[0] - - -def _client_display_name(client: dict) -> str: - user_data = client.get('userData') or {} - return ( - user_data.get('clientName') - or client.get('clientName') - or client.get('clientId') - or 'Connection' - ) - - -def _create_remote_client(manager, protocol: str, server: dict, name: str, source_client: Optional[dict] = None): - proto_info = server.get('protocols', {}).get(protocol, {}) - port = proto_info.get('port', '55424') - base = protocol_base(protocol) - if base == 'telemt': - user_data = (source_client or {}).get('userData') or {} - return manager.add_client( - protocol, name, server['host'], port, - telemt_quota=user_data.get('quota'), - telemt_expiry=user_data.get('expiry'), - secret=user_data.get('token'), - user_ad_tag=user_data.get('user_ad_tag'), - max_tcp_conns=user_data.get('max_tcp_conns'), - ) - if base == 'wireguard': - return manager.add_client(name, server['host']) - return manager.add_client(protocol, name, server['host'], port) - - -def _move_connections_sync( - source_server_id: int, - target_server_id: int, - protocol: str, - client_ids: List[str], - target_protocol: Optional[str] = None, - delete_source: bool = True, -) -> dict: - data = load_data() - if source_server_id >= len(data['servers']) or target_server_id >= len(data['servers']): - raise ValueError('Server not found') - if source_server_id == target_server_id: - raise ValueError('Source and target server must be different') - if protocol_base(protocol) == 'xui': - raise ValueError('Moving 3x-ui connections between servers is not supported') - - source_server = data['servers'][source_server_id] - target_server = data['servers'][target_server_id] - resolved_target_protocol = target_protocol or resolve_protocol_on_server(target_server, protocol) - if not resolved_target_protocol: - raise ValueError( - f'Target server does not have {protocol_display_name(protocol)} installed' - ) - - source_ssh = get_ssh(source_server) - target_ssh = get_ssh(target_server) - source_ssh.connect() - target_ssh.connect() - - source_manager = get_protocol_manager(source_ssh, protocol) - source_clients = _manager_call(source_manager, 'get_clients', protocol) or [] - clients_map = {c.get('clientId'): c for c in source_clients if c.get('clientId')} - - target_manager = get_protocol_manager(target_ssh, resolved_target_protocol) - moved = [] - failed = [] - - try: - for client_id in client_ids: - client = clients_map.get(client_id) - if not client: - failed.append({'client_id': client_id, 'error': 'Client not found on source server'}) - continue - - user_data = client.get('userData') or {} - base = protocol_base(protocol) - if user_data.get('externalClient') and not user_data.get('clientPrivateKey') and base in ( - 'awg', 'awg2', 'awg_legacy', 'wireguard', - ): - failed.append({ - 'client_id': client_id, - 'error': 'External/native client cannot be moved (no private key)', - }) - continue - - name = _client_display_name(client) - enabled = client.get('enabled', True) - if user_data.get('enabled') is False: - enabled = False - - try: - created = _create_remote_client( - target_manager, resolved_target_protocol, target_server, name, client, - ) - new_client_id = created.get('client_id') - if not new_client_id: - failed.append({'client_id': client_id, 'error': 'Failed to create client on target server'}) - continue - - if not enabled: - _manager_call( - target_manager, 'toggle_client', - resolved_target_protocol, new_client_id, False, - ) - - user_conn = next( - ( - uc for uc in data.get('user_connections', []) - if uc.get('client_id') == client_id - and uc.get('server_id') == source_server_id - and uc.get('protocol') == protocol - ), - None, - ) - if user_conn: - user_conn['server_id'] = target_server_id - user_conn['client_id'] = new_client_id - user_conn['protocol'] = resolved_target_protocol - user_conn['name'] = name - elif client.get('assigned_user_id'): - data.setdefault('user_connections', []).append({ - 'id': str(uuid.uuid4()), - 'user_id': client['assigned_user_id'], - 'server_id': target_server_id, - 'protocol': resolved_target_protocol, - 'client_id': new_client_id, - 'name': name, - 'created_at': datetime.now().isoformat(), - }) - - if delete_source: - _manager_call(source_manager, 'remove_client', protocol, client_id) - data['user_connections'] = [ - uc for uc in data.get('user_connections', []) - if not ( - uc.get('client_id') == client_id - and uc.get('server_id') == source_server_id - and uc.get('protocol') == protocol - ) - ] - - moved.append({ - 'client_id': client_id, - 'new_client_id': new_client_id, - 'name': name, - }) - except Exception as exc: - logger.exception('Failed to move client %s', client_id) - failed.append({'client_id': client_id, 'error': str(exc)}) - finally: - source_ssh.disconnect() - target_ssh.disconnect() - - if moved: - save_data(data) - - return { - 'status': 'success' if moved else 'error', - 'moved': moved, - 'failed': failed, - 'target_server_id': target_server_id, - 'target_protocol': resolved_target_protocol, - 'message': f'Moved {len(moved)} connection(s)' + (f', {len(failed)} failed' if failed else ''), - } - - async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None): """ Executes multiple SSH operations efficiently. @@ -2191,12 +2014,10 @@ class ConnectionActionRequest(BaseModel): client_id: str = '' -class MoveConnectionsRequest(BaseModel): +class ToggleConnectionRequest(BaseModel): protocol: str = 'awg' - target_server_id: int - client_ids: List[str] - target_protocol: Optional[str] = None - delete_source: bool = True + client_id: str = '' + enable: bool = True class AddUserRequest(BaseModel): @@ -2739,21 +2560,12 @@ async def server_detail(request: Request, server_id: int): return RedirectResponse(url='/') server = data['servers'][server_id] users_list = data.get('users', []) - servers_for_move = [ - { - 'id': idx, - 'name': srv.get('name') or srv.get('host') or f'Server {idx + 1}', - 'host': srv.get('host') or '', - } - for idx, srv in enumerate(data['servers']) - ] return tpl( request, 'server.html', server=server, server_id=server_id, users=users_list, - servers_for_move=servers_for_move, ) @@ -4569,35 +4381,6 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne return JSONResponse({'error': str(e)}, status_code=500) -@app.post('/api/servers/{server_id}/connections/move', tags=["Connections"]) -async def api_move_connections(request: Request, server_id: int, req: MoveConnectionsRequest): - if not _check_admin(request): - return JSONResponse({'error': 'Forbidden'}, status_code=403) - if not req.client_ids: - return JSONResponse({'error': 'No connections selected'}, status_code=400) - try: - result = await asyncio.to_thread( - _move_connections_sync, - server_id, - req.target_server_id, - req.protocol, - req.client_ids, - req.target_protocol, - bool(req.delete_source), - ) - if not result.get('moved'): - return JSONResponse( - {'error': result.get('message') or 'Move failed', **result}, - status_code=400, - ) - return result - except ValueError as e: - return JSONResponse({'error': str(e)}, status_code=400) - except Exception as e: - logger.exception('Error moving connections') - return JSONResponse({'error': str(e)}, status_code=500) - - @app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"]) async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): if not _check_admin(request): @@ -6657,15 +6440,13 @@ 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 get_panel_listen_port(data), + "port": port, } + logger.info(f"Amnezia Web Panel {CURRENT_VERSION} listening on http://0.0.0.0:{port}") if panel_ssl_active(data) and cert_file and key_file: if os.path.exists(cert_file) and os.path.exists(key_file): diff --git a/docker-compose.yml b/docker-compose.yml index 608e9ce..b30ea92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,15 @@ 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}"] interval: 10s @@ -23,7 +22,6 @@ services: context: . dockerfile: Dockerfile image: amnezia-panel:local - container_name: amnezia_panel depends_on: db: condition: service_healthy @@ -33,15 +31,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/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: diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..55264f4 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -e + +PORT="${PORT:-${APP_PORT:-5000}}" + +echo "Waiting for PostgreSQL at ${DATABASE_URL%%@*}@…" +python3 - <<'PY' +import os +import sys +import time + +url = os.environ.get("DATABASE_URL", "").strip() +if not url: + print("DATABASE_URL is not set", file=sys.stderr) + sys.exit(1) + +import psycopg + +deadline = time.time() + 120 +last_err = None +while time.time() < deadline: + try: + with psycopg.connect(url, connect_timeout=5): + break + except Exception as exc: + last_err = exc + time.sleep(2) +else: + print(f"PostgreSQL not ready: {last_err}", file=sys.stderr) + sys.exit(1) +PY + +echo "Starting Amnezia Web Panel on 0.0.0.0:${PORT}" +exec uvicorn app:app \ + --host 0.0.0.0 \ + --port "${PORT}" \ + --proxy-headers \ + --forwarded-allow-ips='*' diff --git a/static/css/style.css b/static/css/style.css index 65aa2d2..f7060bd 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1295,45 +1295,6 @@ a:hover { background: var(--bg-card-hover); } -.conn-select-wrap { - flex-shrink: 0; - display: flex; - align-items: center; - cursor: pointer; -} - -.conn-select { - width: 16px; - height: 16px; - accent-color: var(--accent); -} - -.connections-bulk-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - margin-bottom: var(--space-sm); - padding: var(--space-sm) var(--space-md); - border: 1px dashed var(--border-color); - border-radius: var(--radius-sm); - background: var(--bg-secondary); -} - -.connections-select-all { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: 0.85rem; - color: var(--text-secondary); - cursor: pointer; -} - -.connections-selected-count { - font-size: 0.8rem; - color: var(--text-muted); -} - .client-info { display: flex; align-items: center; diff --git a/templates/server.html b/templates/server.html index feb8833..e931444 100644 --- a/templates/server.html +++ b/templates/server.html @@ -544,23 +544,12 @@ - - -
- - -