Compare commits

..
2 Commits
Author SHA1 Message Date
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
9 changed files with 718 additions and 84 deletions
+11 -1
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.
@@ -87,6 +88,9 @@ Configuration panel for system parameters and preferences:
* 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.
@@ -224,6 +228,12 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### 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).
+232 -14
View File
@@ -102,7 +102,7 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.4.0"
CURRENT_VERSION = "v2.5.1"
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'))
@@ -138,6 +138,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'
@@ -373,6 +374,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'
@@ -1998,6 +2145,11 @@ class ApplyUpdateRequest(BaseModel):
allow_dirty: bool = False
class NordvpnConnectRequest(BaseModel):
country: Optional[str] = ''
city: Optional[str] = ''
# ======================== Startup ========================
@app.on_event("startup")
@@ -5530,7 +5682,7 @@ async def api_check_updates(request: Request):
'releases_url': f'{RELEASES_REPO_URL}/releases',
'body': body,
'can_auto_update': bool(mode.get('can_auto_update')),
'update_mode': mode.get('mode'),
'update_mode': mode.get('update_method') or mode.get('mode'),
'mode': mode,
}
except Exception as e:
@@ -5538,26 +5690,28 @@ async def api_check_updates(request: Request):
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):
"""Pull the selected release tag from git.evilfox.cc and restart the panel."""
"""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:
# Resolve latest if UI did not pass a tag
try:
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 and resp.content:
payload = resp.json()
target = (payload.get('tag_name') or payload.get('name') or '').strip()
except Exception:
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)
@@ -5567,13 +5721,54 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
bool(req.allow_dirty),
)
if result.get('status') != 'success':
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
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(1.5)
return 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(1.5)
result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION
return result
@app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request):
if not _check_admin(request):
@@ -5583,6 +5778,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(),
}
@@ -5668,6 +5864,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)
+200 -55
View File
@@ -1,14 +1,19 @@
"""Panel self-update from the EvilFox Gitea repository."""
"""Panel self-update from the EvilFox Gitea repository (git or release archive)."""
from __future__ import annotations
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__)
@@ -18,6 +23,13 @@ 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()
@@ -51,7 +63,6 @@ def parse_version(value: str) -> tuple:
parts.append(int(m.group(1)) if m else 0)
while len(parts) < 3:
parts.append(0)
# release > prerelease: empty pre sorts higher
pre_rank = 0 if not pre else 1
return (parts[0], parts[1], parts[2], pre_rank, pre)
@@ -60,6 +71,13 @@ 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(
@@ -83,23 +101,62 @@ class UpdateManager:
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:
return os.environ.get('PANEL_UPDATE_ALLOW_DOCKER', '').strip().lower() in ('1', 'true', 'yes')
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 = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
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
can_auto = bool(git_ok and not frozen)
mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual'))
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': mode,
'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(),
@@ -131,31 +188,27 @@ class UpdateManager:
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
return code == 0 and bool(out.strip())
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
mode = self.detect_mode()
if not mode['can_auto_update']:
return {
'status': 'error',
'message': (
'Auto-update requires a git checkout of the panel sources. '
'Docker/binary installs: pull a new image or download a release manually.'
),
'mode': mode,
}
target = (target_version or '').strip()
if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if not target.startswith('v'):
target = 'v' + target
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. Commit/stash them or set allow_dirty, '
'then retry auto-update.'
'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()
@@ -168,35 +221,28 @@ class UpdateManager:
timeout=180,
)
if code != 0:
return {
'status': 'error',
'message': f'git fetch failed: {stderr or out or code}',
}
return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
# Prefer annotated/lightweight tag; fall back to remote main tip if tag missing
# Ensure tag is present locally
code, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
self.app_root,
timeout=15,
)
if code == 0:
checkout_ref = target
else:
# tag might only exist as FETCH_HEAD / remote tag
code2, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'],
if code != 0:
_run(
['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
self.app_root,
timeout=15,
timeout=120,
)
checkout_ref = target if code2 == 0 else f'tags/{target}'
checkout_ref = f'tags/{target}'
code, out, stderr = _run(
['git', 'checkout', '--force', checkout_ref],
self.app_root,
timeout=60,
)
if code != 0:
# last resort: checkout remote main
code_m, out_m, err_m = _run(
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
self.app_root,
@@ -209,31 +255,130 @@ class UpdateManager:
}
checkout_ref = f'{UPDATE_REMOTE}/main'
req = os.path.join(self.app_root, 'requirements.txt')
pip_log = ''
if os.path.isfile(req):
code_p, out_p, err_p = _run(
[sys.executable, '-m', 'pip', 'install', '-r', req],
self.app_root,
timeout=300,
)
pip_log = (out_p or err_p or '')[:1500]
if code_p != 0:
return {
'status': 'error',
'message': f'pip install failed after checkout: {err_p or out_p or code_p}',
'checkout': checkout_ref,
}
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}. Panel will restart.',
'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]:
base = repo_url()
return [
f'{base}/archive/{tag}.zip',
f'{base}/archive/{tag.lstrip("v")}.zip',
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/archive/{tag}.zip',
]
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 _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'}
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(delay_sec: float = 1.5) -> None:
def _restart():
+155 -9
View File
@@ -284,6 +284,30 @@
</button>
</div>
</div>
<div class="tunnel-row" data-provider="nordvpn" 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>NordVPN</strong>
<span class="badge badge-secondary" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="nordvpnStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_status_unknown') }}</div>
<div id="nordvpnServerText" class="form-hint hidden" style="margin-bottom: var(--space-sm);"></div>
<div id="nordvpnHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_hint') }}</div>
<div class="grid" style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<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 style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" 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 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>
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
</div>
@@ -598,11 +622,15 @@
</div>
<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>
<button type="button" class="btn btn-primary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<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>
@@ -956,6 +984,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
@@ -1025,6 +1054,81 @@
}
}
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');
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 ? _('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);
}
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`);
@@ -1514,16 +1618,26 @@
statusDiv.classList.remove('hidden');
if (modeHint) {
const mode = data.update_mode || (data.mode && data.mode.mode) || '';
if (data.can_auto_update) {
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
} else if (mode === 'docker') {
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker install: rebuild/pull image, or use a git checkout for one-click update.') }}";
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_manual')|default('One-click update needs a git clone. You can still download a release.') }}";
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
}
}
const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) {
upgradeBtn.disabled = !data.can_auto_update;
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)';
@@ -1556,7 +1670,7 @@
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
return;
}
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git and restart the panel?') }} " + target)) {
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
return;
}
applyBtn.disabled = true;
@@ -1565,7 +1679,7 @@
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: false,
allow_dirty: true,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
@@ -1577,6 +1691,38 @@
}
}
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…') }}";
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');
setTimeout(() => { window.location.reload(); }, 4000);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
await checkForUpdates(true);
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
// Auto-check once on Settings load
setTimeout(() => checkForUpdates(true), 800);
+24 -1
View File
@@ -489,7 +489,13 @@
"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_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",
@@ -505,6 +511,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",
+24 -1
View File
@@ -448,6 +448,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",
@@ -535,5 +552,11 @@
"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_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"
}
+24 -1
View File
@@ -448,6 +448,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",
@@ -535,5 +552,11 @@
"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_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"
}
+24 -1
View File
@@ -489,7 +489,13 @@
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
"auto_update_ready": "Для этой установки доступно автообновление через git.",
"auto_update_docker": "Docker: пересоберите/подтяните образ или используйте git clone для обновления в один клик.",
"auto_update_manual": "Обновление в один клик требует git clone. Можно скачать релиз вручную.",
"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",
@@ -505,6 +511,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": "Ошибка подключения",
+24 -1
View File
@@ -448,6 +448,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",
@@ -535,5 +552,11 @@
"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_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"
}