Compare commits

...
3 Commits
Author SHA1 Message Date
orohiandCursor 2746f981af Simplify Docker CMD to uvicorn directly (v2.5.10).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:59:43 +03:00
orohiandCursor 4f5a1d7554 Fix panel startup crash and remove move-connections feature (v2.5.9).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 11:44:35 +03:00
orohi ff7cc5c592 v2.5.6: fix 404 after update without tunnel 2026-07-28 11:00:34 +03:00
15 changed files with 217 additions and 515 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+6 -3
View File
@@ -5,6 +5,9 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
APP_PORT=5000 \ APP_PORT=5000 \
PORT=5000 \
PANEL_IN_DOCKER=1 \
PANEL_BEHIND_PROXY=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app WORKDIR /app
@@ -20,7 +23,7 @@ COPY . .
EXPOSE 5000 EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/health" >/dev/null || exit 1 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", "*"]
+43 -5
View File
@@ -188,22 +188,48 @@ Mac
```bash ```bash
cp .env.example .env cp .env.example .env
# set SECRET_KEY and strong POSTGRES_PASSWORD in .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 docker compose up -d --build
``` ```
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`). 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 <container>` and redeploy Traefik.
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
| `Dockerfile` | Production image (Python 3.12) | | `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 | | `.env.example` | Environment template |
**Environment** **Environment**
| Variable | Default | Description | | 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 | | `SECRET_KEY` | (random) | Session signing key — set in production |
| `DATABASE_URL` | compose DSN | PostgreSQL connection string | | `DATABASE_URL` | compose DSN | PostgreSQL connection string |
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service | | `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
@@ -228,12 +254,24 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### 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 ### 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. * **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 ### v2.5.3
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable. * **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
+60 -236
View File
@@ -29,7 +29,8 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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 starlette.middleware.sessions import SessionMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List, Dict from typing import Optional, List, Dict
@@ -102,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export 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.10"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -225,8 +226,9 @@ def tpl(request, template, **kwargs):
def get_panel_listen_port(data: Optional[dict] = None) -> int: def get_panel_listen_port(data: Optional[dict] = None) -> int:
for env_key in ('APP_PORT', 'PORT'):
try: try:
env_port = int(os.environ.get('APP_PORT', '0') or '0') env_port = int(os.environ.get(env_key, '0') or '0')
except ValueError: except ValueError:
env_port = 0 env_port = 0
if env_port: if env_port:
@@ -241,14 +243,16 @@ def get_panel_listen_port(data: Optional[dict] = None) -> int:
def panel_ssl_active(data: Optional[dict] = None) -> bool: 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: if data is None:
data = load_data() data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {} ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
if not ssl_conf.get('enabled'): if not ssl_conf.get('enabled'):
return False 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') cert_path = ssl_conf.get('cert_path')
key_path = ssl_conf.get('key_path') key_path = ssl_conf.get('key_path')
if ssl_conf.get('cert_text') or ssl_conf.get('key_text'): if ssl_conf.get('cert_text') or ssl_conf.get('key_text'):
@@ -278,6 +282,48 @@ def get_panel_tunnel_target_url():
return f"{scheme}://127.0.0.1:{port}" 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('/health', include_in_schema=False)
@app.get('/api/health', include_in_schema=False) @app.get('/api/health', include_in_schema=False)
async def health_check(): async def health_check():
@@ -1350,186 +1396,6 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool:
return True 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): 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. Executes multiple SSH operations efficiently.
@@ -2148,12 +2014,10 @@ class ConnectionActionRequest(BaseModel):
client_id: str = '' client_id: str = ''
class MoveConnectionsRequest(BaseModel): class ToggleConnectionRequest(BaseModel):
protocol: str = 'awg' protocol: str = 'awg'
target_server_id: int client_id: str = ''
client_ids: List[str] enable: bool = True
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel): class AddUserRequest(BaseModel):
@@ -2696,21 +2560,12 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/') return RedirectResponse(url='/')
server = data['servers'][server_id] server = data['servers'][server_id]
users_list = data.get('users', []) 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( return tpl(
request, request,
'server.html', 'server.html',
server=server, server=server,
server_id=server_id, server_id=server_id,
users=users_list, users=users_list,
servers_for_move=servers_for_move,
) )
@@ -4526,35 +4381,6 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
return JSONResponse({'error': str(e)}, status_code=500) 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"]) @app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"])
async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest):
if not _check_admin(request): if not _check_admin(request):
@@ -6009,7 +5835,7 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
if result.get('restart'): if result.get('restart'):
UpdateManager.schedule_restart(application_path, 1.5) UpdateManager.schedule_restart(application_path, 1.5)
return result return enrich_update_result(request, result)
@app.post('/api/settings/upgrade_panel', tags=["Settings"]) @app.post('/api/settings/upgrade_panel', tags=["Settings"])
@@ -6049,7 +5875,7 @@ async def api_upgrade_panel(request: Request):
UpdateManager.schedule_restart(application_path, 1.5) UpdateManager.schedule_restart(application_path, 1.5)
result['up_to_date'] = False result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION result['previous_version'] = CURRENT_VERSION
return result return enrich_update_result(request, result)
@app.get('/api/settings/tunnels/status', tags=["Settings"]) @app.get('/api/settings/tunnels/status', tags=["Settings"])
@@ -6614,15 +6440,13 @@ if __name__ == '__main__':
with open(key_file, 'w') as f: with open(key_file, 'w') as f:
f.write(ssl_conf['key_text'].strip() + '\n') f.write(ssl_conf['key_text'].strip() + '\n')
try: port = get_panel_listen_port(data)
env_port = int(os.environ.get('APP_PORT', '0') or '0')
except ValueError:
env_port = 0
uvicorn_kwargs = { uvicorn_kwargs = {
"app": app, "app": app,
"host": "0.0.0.0", "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 panel_ssl_active(data) and cert_file and key_file:
if os.path.exists(cert_file) and os.path.exists(key_file): if os.path.exists(cert_file) and os.path.exists(key_file):
+20 -5
View File
@@ -1,16 +1,15 @@
services: services:
db: db:
image: postgres:17 image: postgres:17
container_name: amnezia_panel_db
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-amnezia} POSTGRES_USER: ${POSTGRES_USER:-amnezia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel} POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
volumes: volumes:
- amnezia_pgdata:/var/lib/postgresql/data - amnezia_pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
networks:
- internal
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
interval: 10s interval: 10s
@@ -23,7 +22,6 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: amnezia-panel:local image: amnezia-panel:local
container_name: amnezia_panel
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -33,15 +31,32 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
SECRET_KEY: ${SECRET_KEY:-} SECRET_KEY: ${SECRET_KEY:-}
APP_PORT: "5000" APP_PORT: "5000"
PORT: "5000"
PANEL_IN_DOCKER: "1"
PANEL_BEHIND_PROXY: "1"
volumes: volumes:
- amnezia_data:/app/data - amnezia_data:/app/data
restart: unless-stopped restart: unless-stopped
networks:
- internal
- dokploy-network
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
expose:
- "5000"
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 60s
networks:
internal:
dokploy-network:
external: true
name: dokploy-network
volumes: volumes:
amnezia_data: amnezia_data:
+34 -12
View File
@@ -333,6 +333,15 @@ class UpdateManager:
return only return only
return extract_dir 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: def _sync_tree(self, src_root: str) -> None:
for root, dirs, files in os.walk(src_root): for root, dirs, files in os.walk(src_root):
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS] 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) src_root = self._extract_zip_root(extract_dir)
if not os.path.isdir(src_root): if not os.path.isdir(src_root):
return {'status': 'error', 'message': 'Invalid release archive layout'} 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) self._sync_tree(src_root)
except Exception as e: except Exception as e:
return {'status': 'error', 'message': str(e), 'method': 'archive'} return {'status': 'error', 'message': str(e), 'method': 'archive'}
@@ -406,25 +418,35 @@ class UpdateManager:
@staticmethod @staticmethod
def schedule_restart(app_root: str, 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(): def _restart():
time.sleep(max(0.5, float(delay_sec))) time.sleep(max(0.5, float(delay_sec)))
argv = [sys.executable] + sys.argv
cwd = os.path.abspath(app_root or os.getcwd()) 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: try:
os.chdir(cwd) os.chdir(cwd)
os.execv(sys.executable, argv) 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: except Exception:
logger.exception('Failed to restart after update') logger.exception('Failed to restart after update')
os._exit(0) os._exit(1)
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start() threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
+38
View File
@@ -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='*'
-39
View File
@@ -1295,45 +1295,6 @@ a:hover {
background: var(--bg-card-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 { .client-info {
display: flex; display: flex;
align-items: center; align-items: center;
-137
View File
@@ -544,23 +544,12 @@
<option value="telemt">Telemt</option> <option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option> <option value="wireguard">WireGuard</option>
</select> </select>
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
</button>
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()"> <button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
<span>{{ icon('plus') }}</span> {{ _('add') }} <span>{{ icon('plus') }}</span> {{ _('add') }}
</button> </button>
</div> </div>
</div> </div>
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
<label class="connections-select-all">
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
<span>{{ _('select_all') }}</span>
</label>
<span class="connections-selected-count" id="connSelectedCount">0</span>
</div>
<div id="connectionsList"> <div id="connectionsList">
<div class="loading-overlay" id="connectionsLoading" style="display:none;"> <div class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -980,35 +969,6 @@
</div> </div>
</div> </div>
<!-- ===== Move Connections Modal ===== -->
<div class="modal-backdrop" id="moveConnectionsModal">
<div class="modal" style="max-width: 520px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
</div>
<p class="form-hint">{{ _('move_connections_hint') }}</p>
<div class="form-group">
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
<select class="form-select" id="moveTargetServer"></select>
</div>
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
<input type="checkbox" id="moveDeleteSource" checked>
<span>{{ _('move_connections_delete_source') }}</span>
</label>
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
{{ _('move_connections_warning') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Connection Config Modal (with Tabs) ===== --> <!-- ===== Connection Config Modal (with Tabs) ===== -->
<div class="modal-backdrop" id="configModal"> <div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;"> <div class="modal" style="max-width: 600px;">
@@ -1130,7 +1090,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }}; const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }}; const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
@@ -2649,95 +2608,6 @@
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' }); document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
} }
const selectedConnectionIds = new Set();
function updateMoveSelection() {
selectedConnectionIds.clear();
document.querySelectorAll('.conn-select:checked').forEach((el) => {
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
});
const count = selectedConnectionIds.size;
const bulkBar = document.getElementById('connectionsBulkBar');
const moveBtn = document.getElementById('moveConnectionsBtn');
const countEl = document.getElementById('connSelectedCount');
const selectAll = document.getElementById('connSelectAll');
const boxes = document.querySelectorAll('.conn-select');
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
if (selectAll) {
selectAll.indeterminate = count > 0 && count < boxes.length;
selectAll.checked = boxes.length > 0 && count === boxes.length;
}
}
function toggleSelectAllConnections(checked) {
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
updateMoveSelection();
}
function openMoveConnectionsModal() {
if (!selectedConnectionIds.size) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
const select = document.getElementById('moveTargetServer');
select.innerHTML = '';
(ALL_SERVERS || []).forEach((srv) => {
if (srv.id === SERVER_ID) return;
const opt = document.createElement('option');
opt.value = String(srv.id);
opt.textContent = `${srv.name} (${srv.host})`;
select.appendChild(opt);
});
if (!select.options.length) {
showToast(_('move_connections_no_targets'), 'error');
return;
}
openModal('moveConnectionsModal');
}
async function moveSelectedConnections() {
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
const deleteSource = document.getElementById('moveDeleteSource').checked;
const proto = document.getElementById('connProtoSelect').value;
const clientIds = Array.from(selectedConnectionIds);
if (!clientIds.length || Number.isNaN(targetServerId)) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
const btn = document.getElementById('moveConnectionsSubmitBtn');
const text = document.getElementById('moveConnectionsSubmitText');
const spinner = document.getElementById('moveConnectionsSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
protocol: proto,
target_server_id: targetServerId,
client_ids: clientIds,
delete_source: deleteSource,
});
const moved = (data.moved || []).length;
const failed = (data.failed || []).length;
let msg = _('move_connections_success').replace('{}', String(moved));
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
showToast(msg, failed && !moved ? 'error' : 'success');
closeModal('moveConnectionsModal');
selectedConnectionIds.clear();
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = _('move_connections');
}
}
async function loadConnections() { async function loadConnections() {
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading'); const loading = document.getElementById('connectionsLoading');
@@ -2752,14 +2622,11 @@
loading.style.display = ''; loading.style.display = '';
emptyEl.classList.add('hidden'); emptyEl.classList.add('hidden');
listEl.innerHTML = ''; listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try { try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`); const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none'; loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) { if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden'); emptyEl.classList.remove('hidden');
updateMoveSelection();
return; return;
} }
window.connectionsStore = {}; window.connectionsStore = {};
@@ -2803,9 +2670,6 @@
listEl.innerHTML += ` listEl.innerHTML += `
<div class="client-item" style="${disabledStyle}"> <div class="client-item" style="${disabledStyle}">
<label class="conn-select-wrap">
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
</label>
<div class="client-info"> <div class="client-info">
<div class="client-avatar">${initial}</div> <div class="client-avatar">${initial}</div>
<div> <div>
@@ -2822,7 +2686,6 @@
</div> </div>
`; `;
}); });
updateMoveSelection();
} catch (err) { } catch (err) {
loading.style.display = 'none'; loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error'); showToast(_('connections_load_error') + ': ' + err.message, 'error');
+9 -7
View File
@@ -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(); const started = Date.now();
while (Date.now() - started < timeoutMs) { while (Date.now() - started < timeoutMs) {
try { try {
const res = await fetch('/api/health', { cache: 'no-store' }); const res = await fetch(`${base}/api/health`, { cache: 'no-store' });
if (res.ok) { if (res.ok) {
window.location.reload(); window.location.href = base + '/';
return true; return true;
} }
} catch (_) {} } 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; return false;
} }
@@ -1758,7 +1760,7 @@
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success'); showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
restarting = true; restarting = true;
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}"; applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(); await waitForPanelRestart(120000, data.panel_url);
} catch (err) { } catch (err) {
showToast(_('error') + ': ' + err.message, 'error'); showToast(_('error') + ': ' + err.message, 'error');
} finally { } finally {
@@ -1794,7 +1796,7 @@
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success'); showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
restarting = true; restarting = true;
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}"; text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(); await waitForPanelRestart(120000, data.panel_url);
} catch (err) { } catch (err) {
showToast(_('error') + ': ' + err.message, 'error'); showToast(_('error') + ': ' + err.message, 'error');
await checkForUpdates(true); await checkForUpdates(true);
-13
View File
@@ -101,19 +101,6 @@
"connection_created": "Connection \"{}\" created", "connection_created": "Connection \"{}\" created",
"delete_connection_confirm": "Delete this connection? The configuration will stop working.", "delete_connection_confirm": "Delete this connection? The configuration will stop working.",
"connection_deleted": "Connection deleted", "connection_deleted": "Connection deleted",
"select_all": "Select all",
"connections_selected_count": "{} selected",
"move_connections": "Move",
"move_connections_title": "Move connections to another server",
"move_connections_target": "Target server",
"move_connections_confirm": "Move {} connection(s) to the selected server? Users will need new configs.",
"move_connections_hint": "Recreates selected connections on the target server and updates user assignments.",
"move_connections_warning": "Clients get new keys and IPs. Old configs stop working after move.",
"move_connections_delete_source": "Remove from current server after move",
"move_connections_success": "Moved {} connection(s)",
"move_connections_partial_fail": "{} could not be moved",
"move_connections_none_selected": "Select at least one connection",
"move_connections_no_targets": "No other servers available",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 VPN-key", "vpn_key_tab": "🔑 VPN-key",
"qr_code_tab": "📱 QR-code", "qr_code_tab": "📱 QR-code",
-13
View File
@@ -100,19 +100,6 @@
"connection_created": "اتصال \"{}\" ایجاد شد", "connection_created": "اتصال \"{}\" ایجاد شد",
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.", "delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
"connection_deleted": "اتصال حذف شد", "connection_deleted": "اتصال حذف شد",
"select_all": "انتخاب همه",
"connections_selected_count": "{} انتخاب شده",
"move_connections": "انتقال",
"move_connections_title": "انتقال اتصال‌ها به سرور دیگر",
"move_connections_target": "سرور مقصد",
"move_connections_confirm": "{} اتصال به سرور انتخاب‌شده منتقل شود؟ کاربران به پیکربندی جدید نیاز دارند.",
"move_connections_hint": "اتصال‌های انتخاب‌شده روی سرور مقصد دوباره ساخته می‌شوند و پیوند کاربران حفظ می‌شود.",
"move_connections_warning": "کلیدها و IP جدید صادر می‌شود. پیکربندی‌های قدیمی کار نمی‌کنند.",
"move_connections_delete_source": "پس از انتقال از سرور فعلی حذف شود",
"move_connections_success": "{} اتصال منتقل شد",
"move_connections_partial_fail": "{} منتقل نشد",
"move_connections_none_selected": "حداقل یک اتصال انتخاب کنید",
"move_connections_no_targets": "سرور دیگری برای انتقال وجود ندارد",
"config_tab": "📄 فایل", "config_tab": "📄 فایل",
"vpn_key_tab": "🔑 کلید-VPN", "vpn_key_tab": "🔑 کلید-VPN",
"qr_code_tab": "📱 کد-QR", "qr_code_tab": "📱 کد-QR",
-13
View File
@@ -100,19 +100,6 @@
"connection_created": "Connexion \"{}\" créée", "connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.", "delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"connection_deleted": "Connexion supprimée", "connection_deleted": "Connexion supprimée",
"select_all": "Tout sélectionner",
"connections_selected_count": "{} sélectionné(s)",
"move_connections": "Déplacer",
"move_connections_title": "Déplacer les connexions vers un autre serveur",
"move_connections_target": "Serveur cible",
"move_connections_confirm": "Déplacer {} connexion(s) vers le serveur sélectionné ? Les utilisateurs auront besoin de nouveaux configs.",
"move_connections_hint": "Recrée les connexions sur le serveur cible et conserve les affectations utilisateur.",
"move_connections_warning": "Les clients reçoivent de nouvelles clés et IP. Les anciens configs cessent de fonctionner.",
"move_connections_delete_source": "Supprimer du serveur actuel après le déplacement",
"move_connections_success": "{} connexion(s) déplacée(s)",
"move_connections_partial_fail": "{} n'ont pas pu être déplacées",
"move_connections_none_selected": "Sélectionnez au moins une connexion",
"move_connections_no_targets": "Aucun autre serveur disponible",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 Clé-VPN", "vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR", "qr_code_tab": "📱 Code-QR",
-13
View File
@@ -101,19 +101,6 @@
"connection_created": "Подключение \"{}\" создано", "connection_created": "Подключение \"{}\" создано",
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.", "delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
"connection_deleted": "Подключение удалено", "connection_deleted": "Подключение удалено",
"select_all": "Выбрать все",
"connections_selected_count": "Выбрано: {}",
"move_connections": "Перенести",
"move_connections_title": "Перенос подключений на другой сервер",
"move_connections_target": "Целевой сервер",
"move_connections_confirm": "Перенести {} подключение(й) на выбранный сервер? Пользователям понадобятся новые конфиги.",
"move_connections_hint": "Подключения будут созданы заново на целевом сервере, привязки к пользователям сохранятся.",
"move_connections_warning": "У клиентов будут новые ключи и IP. Старые конфиги перестанут работать.",
"move_connections_delete_source": "Удалить с текущего сервера после переноса",
"move_connections_success": "Перенесено подключений: {}",
"move_connections_partial_fail": "Не удалось перенести: {}",
"move_connections_none_selected": "Выберите хотя бы одно подключение",
"move_connections_no_targets": "Нет других серверов для переноса",
"config_tab": "📄 Конфиг", "config_tab": "📄 Конфиг",
"vpn_key_tab": "🔑 VPN-ключ", "vpn_key_tab": "🔑 VPN-ключ",
"qr_code_tab": "📱 QR-код", "qr_code_tab": "📱 QR-код",
-13
View File
@@ -100,19 +100,6 @@
"connection_created": "连接 \"{}\" 已创建", "connection_created": "连接 \"{}\" 已创建",
"delete_connection_confirm": "确定删除此连接?配置将失效。", "delete_connection_confirm": "确定删除此连接?配置将失效。",
"connection_deleted": "连接已删除", "connection_deleted": "连接已删除",
"select_all": "全选",
"connections_selected_count": "已选 {}",
"move_connections": "迁移",
"move_connections_title": "将连接迁移到另一台服务器",
"move_connections_target": "目标服务器",
"move_connections_confirm": "将 {} 个连接迁移到所选服务器?用户需要新的配置。",
"move_connections_hint": "在目标服务器上重新创建所选连接,并保留用户绑定。",
"move_connections_warning": "客户端将获得新密钥和 IP,旧配置将失效。",
"move_connections_delete_source": "迁移后从当前服务器删除",
"move_connections_success": "已迁移 {} 个连接",
"move_connections_partial_fail": "{} 个无法迁移",
"move_connections_none_selected": "请至少选择一个连接",
"move_connections_no_targets": "没有其他可用服务器",
"config_tab": "📄 配置文件", "config_tab": "📄 配置文件",
"vpn_key_tab": "🔑 VPN 密钥", "vpn_key_tab": "🔑 VPN 密钥",
"qr_code_tab": "📱 二维码", "qr_code_tab": "📱 二维码",