Compare commits

...
14 Commits
Author SHA1 Message Date
orohiandCursor 78e69d899a Backup panel PostgreSQL database as .sql dump (v2.6.2).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:40:59 +03:00
orohiandCursor 2d62758716 Restore move connections between servers (v2.6.1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:33:27 +03:00
orohiandCursor d26843a0d1 Release v2.6.0: stable Dokploy/Docker deployment bundle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:15:00 +03:00
orohiandCursor 706d2c9c0d Postgres healthcheck verifies password; document volume password mismatch (v2.5.11).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:06:07 +03:00
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
orohi 2808a49fa5 v2.5.5: fix 404 after panel update 2026-07-28 10:26:08 +03:00
orohi 2973b96713 v2.5.4: move selected connections between servers 2026-07-28 10:14:08 +03:00
orohi 5d63e5d6ef v2.5.3: fix disabled Update panel button 2026-07-28 10:02:57 +03:00
orohi a5b8f26db1 v2.5.2: redesign Tunnels section in Settings 2026-07-28 09:48:19 +03:00
orohi 599e0a487c Enable true one-click panel update from Settings (v2.5.1).
Download release ZIP from git.evilfox.cc when git is unavailable, add upgrade_panel API, and show a primary Update panel button.
2026-07-28 09:41:51 +03:00
orohi 77c6f0dab7 Add NordVPN outbound control in Tunnels (v2.5.0).
Connect and disconnect via official nordvpn CLI from Settings with optional country/city, alongside Cloudflare WARP.
2026-07-28 09:34:10 +03:00
orohi fd257a59f7 Add one-click panel auto-update from git.evilfox.cc (v2.4.0).
Check Gitea releases in Settings and install the latest tag via git fetch/checkout with pip sync and restart when running from a git checkout.
2026-07-28 09:28:15 +03:00
17 changed files with 2549 additions and 184 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", "*"]
+107 -3
View File
@@ -1,6 +1,6 @@
# Amnezia Web Panel
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **NordVPN**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
> ### 🔄 Compatibility with Official Amnezia Client
>
@@ -66,6 +66,7 @@ Configuration panel for system parameters and preferences:
* **NaiveProxy** (stable): HTTPS/HTTP2 camouflage proxy via [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) (Caddy + [klzgrad/forwardproxy](https://github.com/klzgrad/forwardproxy) naïve fork). ACME TLS on the domain, per-client basic auth, `naive+https://` share links. Requires free TCP **80** and **443**. **Use Karing** as the client — do **not** use v2rayN; other clients are untested.
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
* **Cloudflare WARP**: Add and manage WARP-powered connectivity from the panel for routing and network flexibility.
* **NordVPN**: Connect/disconnect outbound NordVPN from Settings via the official CLI (optional country/city).
* **🛠 Services**:
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
* **AdGuard Home**: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
@@ -85,6 +86,11 @@ Configuration panel for system parameters and preferences:
* **🌐 Internationalization (i18n)**:
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
* Native **RTL (Right-to-Left)** support for Persian language.
* **🔄 Auto-update**:
* Settings → About checks releases on [git.evilfox.cc](https://git.evilfox.cc/test2/Amnezia-Web-Panel-main) and can install the latest tag via git + restart (when the panel runs from a git checkout).
* **🌍 Tunnels & outbound VPN**:
* **Cloudflare Quick Tunnel** and **ngrok** expose the local panel to the internet.
* **Cloudflare WARP** and **NordVPN** route the host outbound (no public panel URL).
* **👥 Advanced User Management**:
* Role-based access (Admin, Support, Regular User).
* Traffic limits, status monitoring, and account expiration.
@@ -182,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 |
@@ -222,6 +277,55 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### 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.
### v2.5.2
* **Tunnels UI redesign** — Settings → Tunnels: grouped layout (public access / outbound VPN), provider cards, status pills, responsive grid.
### v2.5.1
* **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts.
### v2.5.0
* **NordVPN in Tunnels** — connect/disconnect outbound NordVPN from Settings (official `nordvpn` CLI; optional country/city).
### v2.4.0
* **Auto-update from Gitea**: Settings → About checks https://git.evilfox.cc/test2/Amnezia-Web-Panel-main releases and can install the latest tag via `git fetch` + checkout, then restart (git install only).
### v2.3.0
* **Cascade removed** — double-VPN feature dropped from the panel (manager, API, UI, i18n).
+673 -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
@@ -46,6 +47,7 @@ from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager
from managers.wireguard_manager import WireGuardManager
from managers.backup_manager import BackupManager
from managers.update_manager import UpdateManager, version_gt, api_latest_url, repo_url
import telegram_bot as tg_bot
# Configure logging
@@ -101,19 +103,22 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.3.0"
RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main"
RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest"
CURRENT_VERSION = "v2.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'))
TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json'))
# 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,
@@ -137,6 +142,7 @@ TUNNEL_RUNTIMES = {
TUNNEL_LOCK = threading.Lock()
TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+')
WARP_CLI_COMMAND = 'warp-cli.exe' if os.name == 'nt' else 'warp-cli'
NORDVPN_CLI_COMMAND = 'nordvpn.exe' if os.name == 'nt' else 'nordvpn'
@@ -222,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
@@ -237,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:
@@ -372,6 +462,152 @@ def disable_warp():
return get_warp_status()
def get_nordvpn_cli_binary():
found = shutil.which(NORDVPN_CLI_COMMAND)
if found:
return found
if os.name == 'nt':
for base in (os.environ.get('ProgramFiles'), os.environ.get('ProgramFiles(x86)')):
if not base:
continue
for sub in ('NordVPN', 'NordVPN NordSec'):
candidate = os.path.join(base, sub, 'nordvpn.exe')
if os.path.exists(candidate):
return candidate
return None
def is_nordvpn_cli_installed():
return bool(get_nordvpn_cli_binary())
def _nordvpn_install_hint():
system = platform.system().lower()
if system == 'windows':
return (
'Install NordVPN from https://nordvpn.com/download/ or Microsoft Store, '
'then restart this panel.'
)
if system == 'darwin':
return 'Install NordVPN for macOS from https://nordvpn.com/download/, then restart this panel.'
return (
'Install NordVPN CLI: sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh), '
'add your user to the nordvpn group, run nordvpn login, then restart this panel.'
)
def run_nordvpn_cli(*args, timeout: int = 30):
binary = get_nordvpn_cli_binary()
if not binary:
raise RuntimeError(_nordvpn_install_hint())
creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
result = subprocess.run(
[binary, *args],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=timeout,
creationflags=creationflags,
)
output = '\n'.join(part.strip() for part in (result.stdout, result.stderr) if part and part.strip())
if result.returncode != 0:
raise RuntimeError(output or f'nordvpn exited with code {result.returncode}')
return output
def _parse_nordvpn_status(output: str):
lowered = (output or '').lower()
if 'not logged in' in lowered or 'login required' in lowered or 'please log in' in lowered:
return 'not_logged_in'
if 'disconnected' in lowered:
return 'disconnected'
if 'connecting' in lowered:
return 'connecting'
if 'connected' in lowered:
return 'connected'
if 'daemon' in lowered or 'nordvpnd' in lowered:
return 'service_unavailable'
return 'unknown'
def _nordvpn_server_line(output: str) -> str:
for line in (output or '').splitlines():
stripped = line.strip()
if not stripped:
continue
low = stripped.lower()
if low.startswith('server:') or low.startswith('country:') or low.startswith('your new ip:'):
return stripped
return ''
def get_nordvpn_status():
installed = is_nordvpn_cli_installed()
status = {
'installed': installed,
'running': False,
'connected': False,
'status': 'not_installed' if not installed else 'unknown',
'server': '',
'raw': '',
'last_error': '' if installed else _nordvpn_install_hint(),
'install_hint': _nordvpn_install_hint(),
}
if not installed:
return status
try:
output = run_nordvpn_cli('status', timeout=15)
parsed = _parse_nordvpn_status(output)
status.update({
'running': parsed in ('connected', 'connecting'),
'connected': parsed == 'connected',
'status': parsed,
'server': _nordvpn_server_line(output),
'raw': output,
'last_error': '',
})
except Exception as e:
status['last_error'] = str(e)
lowered = str(e).lower()
if 'not logged in' in lowered or 'login' in lowered:
status['status'] = 'not_logged_in'
elif 'daemon' in lowered or 'nordvpnd' in lowered or 'service' in lowered or 'permission' in lowered:
status['status'] = 'service_unavailable'
else:
status['status'] = 'error'
return status
def enable_nordvpn(country: str = '', city: str = ''):
current = get_nordvpn_status()
if not current.get('installed'):
raise RuntimeError(current.get('install_hint') or _nordvpn_install_hint())
if current.get('status') == 'not_logged_in':
raise RuntimeError(
'NordVPN is not logged in. Run `nordvpn login` on this host (browser flow), then retry.'
)
args = ['connect']
country = (country or '').strip()
city = (city or '').strip()
if country and city:
args.extend([country, city])
elif country:
args.append(country)
run_nordvpn_cli(*args, timeout=45)
time.sleep(1)
return get_nordvpn_status()
def disable_nordvpn():
current = get_nordvpn_status()
if not current.get('installed'):
raise RuntimeError(current.get('install_hint') or _nordvpn_install_hint())
run_nordvpn_cli('disconnect', timeout=30)
time.sleep(0.5)
return get_nordvpn_status()
def get_tunnel_command_name(provider: str):
if provider == 'cloudflare':
return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared'
@@ -1163,6 +1399,186 @@ 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.
@@ -1787,6 +2203,14 @@ class ToggleConnectionRequest(BaseModel):
enable: bool = True
class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg'
target_server_id: int
client_ids: List[str]
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel):
username: str
password: str
@@ -1992,6 +2416,16 @@ class TunnelStartRequest(BaseModel):
authtoken: Optional[str] = None
class ApplyUpdateRequest(BaseModel):
target_version: Optional[str] = None
allow_dirty: bool = False
class NordvpnConnectRequest(BaseModel):
country: Optional[str] = ''
city: Optional[str] = ''
# ======================== Startup ========================
@app.on_event("startup")
@@ -2096,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 = []
@@ -2299,7 +2751,22 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/')
server = data['servers'][server_id]
users_list = data.get('users', [])
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list)
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,
)
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
@@ -4114,6 +4581,35 @@ 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):
@@ -5459,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', {})
@@ -5498,6 +5994,8 @@ async def api_check_updates(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
updater = UpdateManager(application_path, CURRENT_VERSION)
mode = updater.detect_mode()
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(
RELEASES_API_LATEST,
@@ -5511,20 +6009,104 @@ async def api_check_updates(request: Request):
data = resp.json() if resp.content else {}
latest = (data.get('tag_name') or data.get('name') or '').strip()
html_url = (data.get('html_url') or f'{RELEASES_REPO_URL}/releases').strip()
body = (data.get('body') or '')[:4000]
current = CURRENT_VERSION
update_available = bool(latest) and latest != current
update_available = bool(latest) and version_gt(latest, current)
return {
'current_version': current,
'latest_version': latest,
'update_available': update_available,
'html_url': html_url,
'releases_url': f'{RELEASES_REPO_URL}/releases',
'body': body,
'can_auto_update': bool(mode.get('can_auto_update')),
'update_mode': mode.get('update_method') or mode.get('mode'),
'mode': mode,
}
except Exception as e:
logger.exception('Error checking for updates')
return JSONResponse({'error': str(e)}, status_code=502)
async def _fetch_latest_release_tag() -> str:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(RELEASES_API_LATEST, headers={'Accept': 'application/json'})
if resp.status_code >= 400:
raise RuntimeError(f'Release API returned HTTP {resp.status_code}')
data = resp.json() if resp.content else {}
return (data.get('tag_name') or data.get('name') or '').strip()
@app.post('/api/settings/apply_update', tags=["Settings"])
async def api_apply_update(request: Request, req: ApplyUpdateRequest):
"""Install a release tag (git checkout or archive download) and restart the panel."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION)
target = (req.target_version or '').strip()
if not target:
try:
target = await _fetch_latest_release_tag()
except Exception as e:
logger.exception('Failed to resolve latest tag for apply_update')
return JSONResponse({'error': str(e)}, status_code=502)
if not target:
return JSONResponse({'error': 'No target version specified'}, status_code=400)
result = await asyncio.to_thread(
updater.apply_update,
target,
bool(req.allow_dirty),
)
if result.get('status') != 'success':
status_code = 409 if result.get('needs_confirm_dirty') else 500
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
if result.get('restart'):
UpdateManager.schedule_restart(application_path, 1.5)
return enrich_update_result(request, result)
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
async def api_upgrade_panel(request: Request):
"""One-click: fetch latest release, install if newer, restart."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION)
mode = updater.detect_mode()
if not mode.get('can_auto_update'):
return JSONResponse({
'error': (
'In-panel update is not available for this install. '
'Use git clone / writable app directory, or rebuild Docker on the host.'
),
'mode': mode,
}, status_code=400)
try:
latest = await _fetch_latest_release_tag()
except Exception as e:
return JSONResponse({'error': str(e)}, status_code=502)
if not latest:
return JSONResponse({'error': 'Could not resolve latest release tag'}, status_code=502)
if not version_gt(latest, CURRENT_VERSION):
return {
'status': 'success',
'up_to_date': True,
'current_version': CURRENT_VERSION,
'latest_version': latest,
'message': 'Already on the latest version',
}
result = await asyncio.to_thread(updater.apply_update, latest, True)
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(application_path, 1.5)
result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION
return enrich_update_result(request, result)
@app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request):
if not _check_admin(request):
@@ -5534,6 +6116,7 @@ async def api_tunnels_status(request: Request):
'cloudflare': get_tunnel_status('cloudflare'),
'ngrok': get_tunnel_status('ngrok'),
'warp': get_warp_status(),
'nordvpn': get_nordvpn_status(),
}
@@ -5619,6 +6202,28 @@ async def api_warp_disconnect(request: Request):
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/settings/nordvpn/connect', tags=["Settings"])
async def api_nordvpn_connect(request: Request, req: NordvpnConnectRequest = NordvpnConnectRequest()):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
return await asyncio.to_thread(enable_nordvpn, req.country or '', req.city or '')
except Exception as e:
logger.exception("Error connecting NordVPN")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/settings/nordvpn/disconnect', tags=["Settings"])
async def api_nordvpn_disconnect(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
return await asyncio.to_thread(disable_nordvpn)
except Exception as e:
logger.exception("Error disconnecting NordVPN")
return JSONResponse({'error': str(e)}, status_code=500)
# @app.post('/api/settings/save')
# async def api_save_settings(request: Request, body: SaveSettingsRequest):
# _check_admin(request)
@@ -5989,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)
@@ -6008,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)
@@ -6064,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:
+452
View File
@@ -0,0 +1,452 @@
"""Panel self-update from the EvilFox Gitea repository (git or release archive)."""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import zipfile
from typing import Optional
logger = logging.getLogger(__name__)
DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main'
DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git'
DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest'
UPDATE_REMOTE = 'panel-update'
ARCHIVE_SKIP_DIRS = frozenset({
'__pycache__', '.git', 'bin', 'data', 'node_modules', '.venv', 'venv', '.cursor',
})
ARCHIVE_SKIP_FILES = frozenset({
'.env', 'data.json', 'tunnels_state.json',
})
def _env(name: str, default: str) -> str:
return (os.environ.get(name) or default).strip()
def repo_url() -> str:
return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/')
def git_url() -> str:
return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL)
def api_latest_url() -> str:
return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST)
def parse_version(value: str) -> tuple:
"""Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple."""
raw = (value or '').strip()
if not raw:
return (0, 0, 0, 1, '')
raw = raw.lstrip('vV')
main, _, pre = raw.partition('-')
parts = []
for piece in main.split('.'):
if piece.isdigit():
parts.append(int(piece))
else:
m = re.match(r'(\d+)', piece or '')
parts.append(int(m.group(1)) if m else 0)
while len(parts) < 3:
parts.append(0)
pre_rank = 0 if not pre else 1
return (parts[0], parts[1], parts[2], pre_rank, pre)
def version_gt(a: str, b: str) -> bool:
return parse_version(a) > parse_version(b)
def normalize_tag(value: str) -> str:
tag = (value or '').strip()
if not tag:
return ''
return tag if tag.startswith('v') else f'v{tag}'
def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]:
try:
proc = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
encoding='utf-8',
errors='replace',
)
return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip()
except FileNotFoundError:
return 127, '', f'Command not found: {cmd[0]}'
except subprocess.TimeoutExpired:
return 124, '', f'Timeout running: {" ".join(cmd)}'
class UpdateManager:
def __init__(self, app_root: str, current_version: str):
self.app_root = os.path.abspath(app_root)
self.current_version = current_version
def _in_docker(self) -> bool:
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _docker_update_allowed(self) -> bool:
if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'):
return False
# In-container archive updates are allowed by default when /app is writable.
return True
def _app_root_writable(self) -> bool:
probe = os.path.join(self.app_root, '.panel_update_write_probe')
try:
with open(probe, 'w', encoding='utf-8') as fh:
fh.write('ok')
os.remove(probe)
return True
except OSError:
return False
def detect_mode(self) -> dict:
frozen = bool(getattr(sys, 'frozen', False))
in_docker = self._in_docker()
git_dir = os.path.join(self.app_root, '.git')
is_git = os.path.isdir(git_dir)
git_ok = False
if is_git and not frozen:
code, _, _ = _run(['git', '--version'], self.app_root, timeout=10)
git_ok = code == 0
writable = self._app_root_writable()
can_git = bool(git_ok and is_git and not frozen)
can_archive = bool(
writable
and not frozen
and (not in_docker or self._docker_update_allowed())
)
can_auto = can_git or can_archive
if can_git:
method = 'git'
elif can_archive:
method = 'archive'
elif in_docker:
method = 'docker'
elif frozen:
method = 'binary'
else:
method = 'manual'
return {
'mode': method,
'update_method': method,
'can_auto_update': can_auto,
'can_git_update': can_git,
'can_archive_update': can_archive,
'is_git': is_git,
'frozen': frozen,
'in_docker': in_docker,
'writable': writable,
'app_root': self.app_root,
'repo_url': repo_url(),
'git_url': git_url(),
}
def _ensure_update_remote(self) -> Optional[str]:
code, out, err = _run(['git', 'remote'], self.app_root, timeout=15)
if code != 0:
return err or out or 'git remote failed'
remotes = set((out or '').split())
target = git_url()
if UPDATE_REMOTE in remotes:
code, _, err = _run(
['git', 'remote', 'set-url', UPDATE_REMOTE, target],
self.app_root,
timeout=15,
)
else:
code, _, err = _run(
['git', 'remote', 'add', UPDATE_REMOTE, target],
self.app_root,
timeout=15,
)
if code != 0:
return err or 'Failed to configure update remote'
return None
def working_tree_dirty(self) -> bool:
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
return code == 0 and bool(out.strip())
def _pip_install(self) -> tuple[bool, str]:
req = os.path.join(self.app_root, 'requirements.txt')
if not os.path.isfile(req):
return True, ''
code_p, out_p, err_p = _run(
[sys.executable, '-m', 'pip', 'install', '-r', req],
self.app_root,
timeout=300,
)
log = (out_p or err_p or '')[:1500]
return code_p == 0, log
def _apply_git_update(self, target: str, allow_dirty: bool) -> dict:
if not allow_dirty and self.working_tree_dirty():
return {
'status': 'error',
'message': (
'Working tree has local changes. Confirm update anyway to force checkout, '
'or commit/stash changes first.'
),
'needs_confirm_dirty': True,
}
err = self._ensure_update_remote()
if err:
return {'status': 'error', 'message': err}
code, out, stderr = _run(
['git', 'fetch', '--tags', '--force', UPDATE_REMOTE],
self.app_root,
timeout=180,
)
if code != 0:
return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
# Ensure tag is present locally
code, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
self.app_root,
timeout=15,
)
if code != 0:
_run(
['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
self.app_root,
timeout=120,
)
checkout_ref = f'tags/{target}'
code, out, stderr = _run(
['git', 'checkout', '--force', checkout_ref],
self.app_root,
timeout=60,
)
if code != 0:
code_m, out_m, err_m = _run(
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
self.app_root,
timeout=60,
)
if code_m != 0:
return {
'status': 'error',
'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}',
}
checkout_ref = f'{UPDATE_REMOTE}/main'
ok, pip_log = self._pip_install()
if not ok:
return {
'status': 'error',
'message': f'pip install failed after checkout: {pip_log[:500]}',
'checkout': checkout_ref,
'method': 'git',
}
return {
'status': 'success',
'message': f'Updated to {target} via git. Panel will restart.',
'target_version': target,
'checkout': checkout_ref,
'pip': pip_log[:500],
'method': 'git',
'restart': True,
}
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()
urls.extend([
f'{base}/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'
for url in self._archive_urls(tag):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Amnezia-Web-Panel-Updater'})
with urllib.request.urlopen(req, timeout=180) as resp:
with open(dest_path, 'wb') as out:
shutil.copyfileobj(resp, out)
if os.path.getsize(dest_path) > 1024:
return
last_err = f'empty archive from {url}'
except urllib.error.HTTPError as e:
last_err = f'HTTP {e.code} for {url}'
except Exception as e:
last_err = str(e)
raise RuntimeError(f'Failed to download release archive: {last_err}')
@staticmethod
def _extract_zip_root(extract_dir: str) -> str:
entries = [e for e in os.listdir(extract_dir) if e not in ('.', '..')]
if len(entries) == 1:
only = os.path.join(extract_dir, entries[0])
if os.path.isdir(only):
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]
rel = os.path.relpath(root, src_root)
dest_root = self.app_root if rel in ('.', '') else os.path.join(self.app_root, rel)
os.makedirs(dest_root, exist_ok=True)
for fname in files:
if fname in ARCHIVE_SKIP_FILES:
continue
src_file = os.path.join(root, fname)
dest_file = os.path.join(dest_root, fname)
shutil.copy2(src_file, dest_file)
def _apply_archive_update(self, target: str) -> dict:
tmpdir = tempfile.mkdtemp(prefix='panel-update-')
zip_path = os.path.join(tmpdir, f'{target}.zip')
extract_dir = os.path.join(tmpdir, 'extract')
try:
self._download_archive(target, zip_path)
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_dir)
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'}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
ok, pip_log = self._pip_install()
if not ok:
return {
'status': 'error',
'message': f'Files updated but pip install failed: {pip_log[:500]}',
'method': 'archive',
}
return {
'status': 'success',
'message': f'Updated to {target} from release archive. Panel will restart.',
'target_version': target,
'pip': pip_log[:500],
'method': 'archive',
'restart': True,
}
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
mode = self.detect_mode()
target = normalize_tag(target_version)
if not target or not re.match(r'^v\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if mode.get('can_git_update'):
return self._apply_git_update(target, allow_dirty)
if mode.get('can_archive_update'):
return self._apply_archive_update(target)
hint = (
'Cannot update from inside this install. '
'Use a git clone or writable source directory, or rebuild the Docker image on the host.'
)
if mode.get('in_docker'):
hint = (
'Docker image install: on the host run '
'`git pull && docker compose up -d --build` '
'(or set PANEL_UPDATE_ALLOW_DOCKER=1 if /app is bind-mounted).'
)
return {'status': 'error', 'message': hint, 'mode': mode}
@staticmethod
def schedule_restart(app_root: str, delay_sec: float = 1.5) -> None:
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _restart():
time.sleep(max(0.5, float(delay_sec)))
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:
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')
os._exit(1)
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
+353
View File
@@ -1282,6 +1282,7 @@ a:hover {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--bg-card);
border: 1px solid var(--border-color);
@@ -1294,6 +1295,45 @@ 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;
@@ -1623,6 +1663,319 @@ a:hover {
gap: var(--space-md);
}
/* ===== Tunnels (Settings) ===== */
.tunnels-card .tunnels-header {
margin-bottom: var(--space-lg);
}
.tunnels-card .tunnels-header .card-title {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-card .tunnels-subtitle {
margin: var(--space-xs) 0 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnels-local {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-lg);
border-radius: var(--radius-md);
border: 1px solid var(--border-focus);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.tunnels-local-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--accent-gradient);
font-size: 1.25rem;
box-shadow: var(--shadow-glow);
}
.tunnels-local-body {
flex: 1;
min-width: 0;
}
.tunnels-local-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.tunnels-local-url {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-local-url code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
color: var(--text-primary);
background: transparent;
padding: 0;
}
.tunnels-section {
margin-bottom: var(--space-lg);
}
.tunnels-section:last-of-type {
margin-bottom: var(--space-md);
}
.tunnels-section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 var(--space-sm) var(--space-xs);
}
.tunnels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
.tunnel-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.tunnel-card:hover {
border-color: var(--border-hover);
}
.tunnel-card--running {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
}
.tunnel-card--connected {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border);
}
.tunnel-card-head {
display: flex;
align-items: flex-start;
gap: var(--space-md);
}
.tunnel-card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
.tunnel-card-icon--local { background: var(--accent-gradient); }
.tunnel-card-meta {
flex: 1;
min-width: 0;
}
.tunnel-card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.tunnel-card-desc {
font-size: 0.78rem;
color: var(--text-muted);
margin: var(--space-xs) 0 0;
line-height: 1.4;
}
.tunnel-status-pill {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-full);
white-space: nowrap;
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.tunnel-status-pill--ok {
background: var(--success-bg);
color: var(--success);
border-color: var(--success-border);
}
.tunnel-status-pill--info {
background: var(--info-bg);
color: var(--info);
border-color: rgba(59, 130, 246, 0.25);
}
.tunnel-card-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tunnel-url-box {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.45;
min-height: 2.5rem;
}
.tunnel-url-box--empty {
font-style: italic;
color: var(--text-muted);
}
.tunnel-url-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.tunnel-url-row:first-child {
margin-top: 0;
}
.tunnel-url-row code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
color: var(--text-accent);
background: transparent;
padding: 0;
}
.tunnel-status-line {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnel-status-line--server {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
color: var(--text-accent);
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.tunnel-hint {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
margin: 0;
}
.tunnel-card-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: auto;
}
.tunnel-card-actions .btn {
flex: 1;
min-width: 7rem;
justify-content: center;
}
.tunnel-card-actions--triple .btn {
min-width: 5.5rem;
}
.tunnel-nordvpn-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
.tunnels-footnote {
margin: var(--space-md) 0 0;
padding: var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border-left: 3px solid var(--accent);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 640px) {
.tunnels-local {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.tunnels-local-url {
flex-direction: column;
}
.tunnels-grid {
grid-template-columns: 1fr;
}
.tunnel-nordvpn-fields {
grid-template-columns: 1fr;
}
}
.mt-md {
margin-top: var(--space-md);
}
+137
View File
@@ -544,12 +544,23 @@
<option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option>
</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()">
<span>{{ icon('plus') }}</span> {{ _('add') }}
</button>
</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 class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div>
@@ -969,6 +980,35 @@
</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) ===== -->
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
@@ -1090,6 +1130,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script>
const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}";
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 }};
@@ -2608,6 +2649,95 @@
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() {
const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading');
@@ -2622,11 +2752,14 @@
loading.style.display = '';
emptyEl.classList.add('hidden');
listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden');
updateMoveSelection();
return;
}
window.connectionsStore = {};
@@ -2670,6 +2803,9 @@
listEl.innerHTML += `
<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-avatar">${initial}</div>
<div>
@@ -2686,6 +2822,7 @@
</div>
`;
});
updateMoveSelection();
} catch (err) {
loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error');
+401 -100
View File
@@ -204,88 +204,147 @@
</div>
<!-- BLOCK Tunnels -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<div class="card tunnels-card">
<div class="tunnels-header">
<h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
</div>
<div class="tunnels-local">
<div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div class="tunnels-local-body">
<div class="tunnels-local-label">{{ _('local_server') }}</div>
<div class="tunnels-local-url">
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div>
</div>
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
</div>
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare Quick Tunnel</strong>
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnels-section">
<h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<div class="tunnels-grid">
<article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</div>
<div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>ngrok Tunnel + Authtoken</strong>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="form-group" style="margin-bottom: var(--space-sm);">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
</div>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare WARP</strong>
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
<article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">ngrok</h5>
<p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</div>
<div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
<div class="tunnels-section">
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
<div class="tunnels-grid">
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">NordVPN</h5>
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
<div class="tunnel-nordvpn-fields">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
</div>
</div>
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
</div>
<!-- BLOCK SSL/HTTPS -->
@@ -545,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"
@@ -564,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>
@@ -597,15 +663,24 @@
<!-- Updated via JS -->
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
<button type="button" class="btn btn-primary" onclick="upgradePanel()" id="upgradePanelBtn" style="flex:1; min-width:180px;">
<span id="upgradePanelBtnText">⬆ {{ _('upgrade_panel') }}</span>
<div class="spinner hidden" id="upgradePanelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
<button type="button" class="btn btn-secondary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-secondary hidden" id="downloadUpdateBtn" style="flex:1; min-width:140px; text-decoration: none; justify-content: center;">
⬇️ {{ _('download_update') }}
</a>
</div>
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
</div>
</div>
</div>
@@ -916,31 +991,45 @@
const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return;
if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url');
return;
}
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
<div class="tunnel-url-row">
<code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div>
`).join('');
}
function setTunnelStatusPill(badge, { ok, info, text }) {
if (!badge) return;
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
badge.textContent = text;
}
function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
badge.textContent = status.installed ? _('installed') : _('not_installed');
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = !!status.running;
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
const running = !!status.running;
setTunnelStatusPill(badge, {
ok: running,
info: !running && status.installed,
text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
});
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = running;
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
if (card) card.classList.toggle('tunnel-card--running', running);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
}
@@ -951,6 +1040,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
@@ -963,12 +1053,16 @@
const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
setTunnelStatusPill(badge, {
ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -976,6 +1070,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
}
async function connectWarp() {
@@ -1020,6 +1115,86 @@
}
}
function updateNordvpnStatus(status) {
const badge = document.getElementById('nordvpnInstallBadge');
const statusText = document.getElementById('nordvpnStatusText');
const serverText = document.getElementById('nordvpnServerText');
const hintText = document.getElementById('nordvpnHintText');
const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText');
const card = document.getElementById('tunnelCard-nordvpn');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
setTunnelStatusPill(badge, {
ok: connected,
info: !connected && installed,
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
if (serverText) {
const serverLine = status.server || '';
serverText.textContent = serverLine;
serverText.classList.toggle('hidden', !serverLine);
}
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
}
async function connectNordvpn() {
const btn = document.getElementById('nordvpnConnectBtn');
const spinner = document.getElementById('nordvpnConnectSpinner');
const text = document.getElementById('nordvpnConnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
const city = (document.getElementById('nordvpnCity') || {}).value || '';
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
country: country.trim(),
city: city.trim(),
});
updateNordvpnStatus(status);
showToast(_('nordvpn_connected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
spinner.classList.add('hidden');
}
}
async function disconnectNordvpn() {
const btn = document.getElementById('nordvpnDisconnectBtn');
const spinner = document.getElementById('nordvpnDisconnectSpinner');
const text = document.getElementById('nordvpnDisconnectBtnText');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
updateNordvpnStatus(status);
showToast(_('nordvpn_disconnected'), 'success');
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
await loadTunnelStatus();
} finally {
btn.disabled = false;
text.textContent = _('nordvpn_disconnect');
spinner.classList.add('hidden');
}
}
async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`);
@@ -1482,42 +1657,168 @@
updateProtocolsForSync();
}
async function checkForUpdates() {
let latestUpdateInfo = null;
async function checkForUpdates(silent) {
const btn = document.getElementById('checkUpdateBtn');
const text = document.getElementById('checkUpdateBtnText');
const spinner = document.getElementById('checkUpdateSpinner');
const statusDiv = document.getElementById('updateStatus');
const downloadBtn = document.getElementById('downloadUpdateBtn');
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
const applyBtn = document.getElementById('applyUpdateBtn');
const modeHint = document.getElementById('updateModeHint');
if (!silent) {
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
}
statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden');
applyBtn.classList.add('hidden');
try {
const data = await apiCall('/api/settings/check_updates');
latestUpdateInfo = data;
const latestVersion = data.latest_version || '';
statusDiv.classList.remove('hidden');
if (modeHint) {
const method = data.update_mode || (data.mode && data.mode.update_method) || (data.mode && data.mode.mode) || '';
if (!data.can_auto_update) {
if (method === 'docker') {
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker: on the host run git pull && docker compose up -d --build.') }}";
} else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a writable install directory or git clone.') }}";
}
} else if (method === 'archive') {
modeHint.textContent = "{{ _('auto_update_archive')|default('One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.') }}";
} else {
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
}
}
const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) {
upgradeBtn.disabled = false;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
}
if (data.update_available && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden');
applyBtn.classList.remove('hidden');
} else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)';
}
} catch (err) {
showToast('Failed to check for updates: ' + err.message, 'error');
if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
spinner.classList.add('hidden');
if (!silent) {
btn.disabled = false;
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
spinner.classList.add('hidden');
}
}
}
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');
const applySpinner = document.getElementById('applyUpdateSpinner');
const target = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
if (!target) {
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
return;
}
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
return;
}
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');
restarting = true;
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(120000, data.panel_url);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
if (!restarting) {
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
}
}
}
async function upgradePanel() {
const btn = document.getElementById('upgradePanelBtn');
const text = document.getElementById('upgradePanelBtnText');
const spinner = document.getElementById('upgradePanelSpinner');
const latest = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
const confirmMsg = latest
? ("{{ _('upgrade_panel_confirm')|default('Download and install') }} " + latest + "?")
: "{{ _('upgrade_panel_confirm_latest')|default('Check for updates and install the latest release?') }}";
if (!confirm(confirmMsg)) return;
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) {
showToast("{{ _('upgrade_panel_up_to_date')|default('Already on the latest version') }}", 'info');
await checkForUpdates(true);
return;
}
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
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 {
if (!restarting) {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
}
// Auto-check once on Settings load
setTimeout(() => checkForUpdates(true), 800);
/* ========== API Tokens ========== */
function fmtTokenDate(iso) {
+60 -4
View File
@@ -101,6 +101,19 @@
"connection_created": "Connection \"{}\" created",
"delete_connection_confirm": "Delete this connection? The configuration will stop working.",
"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",
"vpn_key_tab": "🔑 VPN-key",
"qr_code_tab": "📱 QR-code",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Documentation",
"api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities",
"tunnels_title": "Tunnels",
"tunnels_subtitle": "Expose the panel to the internet or route outbound traffic through a VPN.",
"tunnels_section_inbound": "Public access",
"tunnels_section_outbound": "Outbound VPN",
"tunnel_cloudflare_desc": "Free HTTPS URL via Cloudflare — no account required.",
"tunnel_ngrok_desc": "Stable tunnel with your ngrok authtoken.",
"tunnel_warp_desc": "Route server traffic through Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connect this host to NordVPN servers.",
"local_server": "Local Server",
"tunnel_install_enable": "Install \u0026 Enable",
"tunnel_enable": "Enable Tunnel",
@@ -341,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:",
@@ -481,6 +503,23 @@
"update_available": "New version available",
"download_update": "Download update",
"up_to_date": "You have the latest version",
"apply_update": "Install update",
"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.",
"auto_update_docker": "Docker: one-click update downloads the release ZIP into the container. Rebuild the image to persist across container recreation.",
"auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first",
@@ -496,6 +535,23 @@
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
"nordvpn_install_required": "Install NordVPN first",
"nordvpn_connect": "Connect NordVPN",
"nordvpn_disconnect": "Disconnect",
"nordvpn_connected": "NordVPN connected",
"nordvpn_disconnected": "NordVPN disconnected",
"nordvpn_status_unknown": "NordVPN status is unknown.",
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
"nordvpn_status_connected": "NordVPN is connected.",
"nordvpn_status_connecting": "NordVPN is connecting.",
"nordvpn_status_disconnected": "NordVPN is disconnected.",
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
"nordvpn_status_error": "NordVPN returned an error.",
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
+67 -5
View File
@@ -100,6 +100,19 @@
"connection_created": "اتصال \"{}\" ایجاد شد",
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
"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": "📄 فایل",
"vpn_key_tab": "🔑 کلید-VPN",
"qr_code_tab": "📱 کد-QR",
@@ -256,7 +269,14 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات API",
"api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید",
"tunnels_title": "Tunnels",
"tunnels_title": "تونل‌ها",
"tunnels_subtitle": "پنل را در اینترنت منتشر کنید یا ترافیک خروجی را از طریق VPN هدایت کنید.",
"tunnels_section_inbound": "دسترسی عمومی",
"tunnels_section_outbound": "VPN خروجی",
"tunnel_cloudflare_desc": "آدرس HTTPS رایگان از Cloudflare — بدون ثبت‌نام.",
"tunnel_ngrok_desc": "تونل پایدار با authtoken ngrok شما.",
"tunnel_warp_desc": "هدایت ترافیک سرور از طریق Cloudflare WARP.",
"tunnel_nordvpn_desc": "اتصال این میزبان به سرورهای NordVPN.",
"local_server": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل",
@@ -324,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": "کلید عمومی کلاینت:",
@@ -448,6 +470,23 @@
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
"nordvpn_install_required": "Install NordVPN first",
"nordvpn_connect": "Connect NordVPN",
"nordvpn_disconnect": "Disconnect",
"nordvpn_connected": "NordVPN connected",
"nordvpn_disconnected": "NordVPN disconnected",
"nordvpn_status_unknown": "NordVPN status is unknown.",
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
"nordvpn_status_connected": "NordVPN is connected.",
"nordvpn_status_connecting": "NordVPN is connecting.",
"nordvpn_status_disconnected": "NordVPN is disconnected.",
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
"nordvpn_status_error": "NordVPN returned an error.",
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
@@ -519,4 +558,27 @@
"xui_server_select_label": "سرور 3x-ui",
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک می‌دهد را انتخاب کنید.",
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟"
,
"about_title": "About & Updates",
"current_version": "Current version",
"check_updates": "Check for updates",
"checking_updates": "Checking...",
"update_available": "New version available",
"download_update": "Download update",
"up_to_date": "You have the latest version",
"apply_update": "Install update",
"applying_update": "Updating…",
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
"update_restarting": "Updated. Restarting panel…",
"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.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}
+66 -4
View File
@@ -100,6 +100,19 @@
"connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"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",
"vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 Documentation API",
"api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API",
"tunnels_title": "Tunnels",
"tunnels_subtitle": "Exposez le panneau sur Internet ou routez le trafic sortant via un VPN.",
"tunnels_section_inbound": "Accès public",
"tunnels_section_outbound": "VPN sortant",
"tunnel_cloudflare_desc": "URL HTTPS gratuite via Cloudflare — sans compte.",
"tunnel_ngrok_desc": "Tunnel stable avec votre authtoken ngrok.",
"tunnel_warp_desc": "Routez le trafic du serveur via Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connectez cet hôte aux serveurs NordVPN.",
"local_server": "Serveur local",
"tunnel_install_enable": "Installer et activer",
"tunnel_enable": "Activer le tunnel",
@@ -324,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 :",
@@ -448,6 +470,23 @@
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
"nordvpn_install_required": "Install NordVPN first",
"nordvpn_connect": "Connect NordVPN",
"nordvpn_disconnect": "Disconnect",
"nordvpn_connected": "NordVPN connected",
"nordvpn_disconnected": "NordVPN disconnected",
"nordvpn_status_unknown": "NordVPN status is unknown.",
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
"nordvpn_status_connected": "NordVPN is connected.",
"nordvpn_status_connecting": "NordVPN is connecting.",
"nordvpn_status_disconnected": "NordVPN is disconnected.",
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
"nordvpn_status_error": "NordVPN returned an error.",
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
@@ -519,4 +558,27 @@
"xui_server_select_label": "Serveur 3x-ui",
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l\u0027URL d\u0027abonnement.",
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?"
,
"about_title": "About & Updates",
"current_version": "Current version",
"check_updates": "Check for updates",
"checking_updates": "Checking...",
"update_available": "New version available",
"download_update": "Download update",
"up_to_date": "You have the latest version",
"apply_update": "Install update",
"applying_update": "Updating…",
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
"update_restarting": "Updated. Restarting panel…",
"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.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}
+60 -4
View File
@@ -101,6 +101,19 @@
"connection_created": "Подключение \"{}\" создано",
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
"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": "📄 Конфиг",
"vpn_key_tab": "🔑 VPN-ключ",
"qr_code_tab": "📱 QR-код",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Документация",
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
"tunnels_title": "Туннели",
"tunnels_subtitle": "Публикация панели в интернете или исходящий трафик через VPN.",
"tunnels_section_inbound": "Публичный доступ",
"tunnels_section_outbound": "Исходящий VPN",
"tunnel_cloudflare_desc": "Бесплатный HTTPS-адрес через Cloudflare — без регистрации.",
"tunnel_ngrok_desc": "Стабильный туннель с вашим authtoken ngrok.",
"tunnel_warp_desc": "Маршрутизация трафика сервера через Cloudflare WARP.",
"tunnel_nordvpn_desc": "Подключение хоста к серверам NordVPN.",
"local_server": "Локальный сервер",
"tunnel_install_enable": "Установить и включить",
"tunnel_enable": "Включить туннель",
@@ -341,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": "Публичный ключ клиента:",
@@ -481,6 +503,23 @@
"update_available": "Доступна новая версия",
"download_update": "Скачать обновление",
"up_to_date": "У вас установлена последняя версия",
"apply_update": "Установить обновление",
"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.",
"auto_update_docker": "Docker: обновление в один клик скачивает ZIP релиза в контейнер. Пересоберите образ, чтобы сохранить изменения при пересоздании контейнера.",
"auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
"upgrade_panel": "Обновить панель",
"upgrade_panel_confirm": "Скачать и установить",
"upgrade_panel_confirm_latest": "Проверить обновления и установить последний релиз?",
"upgrade_panel_working": "Обновление панели…",
"upgrade_panel_up_to_date": "Уже установлена последняя версия",
"warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.",
"warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.",
"warp_install_required": "Сначала установите WARP",
@@ -496,6 +535,23 @@
"warp_status_not_registered": "Cloudflare WARP установлен, но ещё не зарегистрирован. Подключение попробует зарегистрировать его автоматически.",
"warp_status_service_unavailable": "Сервис Cloudflare WARP недоступен. Убедитесь, что warp-svc запущен.",
"warp_status_error": "Cloudflare WARP вернул ошибку.",
"nordvpn_hint": "NordVPN направляет исходящий трафик этого хоста через серверы NordVPN. Не создаёт публичный URL панели как Quick Tunnel или ngrok. Перед подключением один раз выполните nordvpn login на хосте.",
"nordvpn_install_hint": "Установите официальный клиент/CLI NordVPN на этом хосте, выполните nordvpn login и перезапустите панель.",
"nordvpn_install_required": "Сначала установите NordVPN",
"nordvpn_connect": "Подключить NordVPN",
"nordvpn_disconnect": "Отключить",
"nordvpn_connected": "NordVPN подключён",
"nordvpn_disconnected": "NordVPN отключён",
"nordvpn_status_unknown": "Статус NordVPN неизвестен.",
"nordvpn_status_not_installed": "CLI NordVPN не установлен на этом хосте.",
"nordvpn_status_connected": "NordVPN подключён.",
"nordvpn_status_connecting": "NordVPN подключается.",
"nordvpn_status_disconnected": "NordVPN отключён.",
"nordvpn_status_not_logged_in": "NordVPN установлен, но не выполнен вход. Запустите nordvpn login на хосте.",
"nordvpn_status_service_unavailable": "Демон NordVPN недоступен. Убедитесь, что nordvpnd запущен.",
"nordvpn_status_error": "NordVPN вернул ошибку.",
"nordvpn_country_placeholder": "Страна (необяз., напр. Germany или de)",
"nordvpn_city_placeholder": "Город (необяз., напр. Berlin)",
"install_starting": "Начинаем установку...",
"server_check_complete": "Проверка завершена",
"server_connection_error": "Ошибка подключения",
+66 -4
View File
@@ -100,6 +100,19 @@
"connection_created": "连接 \"{}\" 已创建",
"delete_connection_confirm": "确定删除此连接?配置将失效。",
"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": "📄 配置文件",
"vpn_key_tab": "🔑 VPN 密钥",
"qr_code_tab": "📱 二维码",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能",
"tunnels_title": "Tunnels",
"tunnels_subtitle": "将面板暴露到互联网,或通过 VPN 路由出站流量。",
"tunnels_section_inbound": "公网访问",
"tunnels_section_outbound": "出站 VPN",
"tunnel_cloudflare_desc": "通过 Cloudflare 获取免费 HTTPS 地址,无需注册。",
"tunnel_ngrok_desc": "使用 ngrok authtoken 的稳定隧道。",
"tunnel_warp_desc": "通过 Cloudflare WARP 路由服务器流量。",
"tunnel_nordvpn_desc": "将此主机连接到 NordVPN 服务器。",
"local_server": "本地服务器",
"tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道",
@@ -324,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": "客户端公钥:",
@@ -448,6 +470,23 @@
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
"warp_status_error": "Cloudflare WARP returned an error.",
"nordvpn_hint": "NordVPN routes this host outbound through NordVPN servers. It does not create a public panel URL like Quick Tunnel or ngrok. Run nordvpn login on the host once before connecting.",
"nordvpn_install_hint": "Install the official NordVPN client/CLI on this host, run nordvpn login, then restart the panel.",
"nordvpn_install_required": "Install NordVPN first",
"nordvpn_connect": "Connect NordVPN",
"nordvpn_disconnect": "Disconnect",
"nordvpn_connected": "NordVPN connected",
"nordvpn_disconnected": "NordVPN disconnected",
"nordvpn_status_unknown": "NordVPN status is unknown.",
"nordvpn_status_not_installed": "NordVPN CLI is not installed on this host.",
"nordvpn_status_connected": "NordVPN is connected.",
"nordvpn_status_connecting": "NordVPN is connecting.",
"nordvpn_status_disconnected": "NordVPN is disconnected.",
"nordvpn_status_not_logged_in": "NordVPN is installed but not logged in. Run nordvpn login on this host.",
"nordvpn_status_service_unavailable": "NordVPN daemon is not available. Make sure nordvpnd is running.",
"nordvpn_status_error": "NordVPN returned an error.",
"nordvpn_country_placeholder": "Country (optional, e.g. Germany or de)",
"nordvpn_city_placeholder": "City (optional, e.g. Berlin)",
"install_starting": "Starting installation...",
"server_check_complete": "Check completed",
"server_connection_error": "Connection error",
@@ -519,4 +558,27 @@
"xui_server_select_label": "3x-ui 服务器",
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?"
,
"about_title": "About & Updates",
"current_version": "Current version",
"check_updates": "Check for updates",
"checking_updates": "Checking...",
"update_available": "New version available",
"download_update": "Download update",
"up_to_date": "You have the latest version",
"apply_update": "Install update",
"applying_update": "Updating…",
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
"update_restarting": "Updated. Restarting panel…",
"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.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}