Compare commits

...
16 Commits
Author SHA1 Message Date
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
orohi 6206697e3b Remove Cascade double-VPN (v2.3.0).
Drop cascade manager, API, server UI, and i18n keys after unstable egress/routing behavior.
2026-07-27 05:08:18 +03:00
orohi 4c19099b04 Fix i18n: strip UTF-8 BOM so translations load again.
PowerShell rewrites had added a BOM that made Python json.load fail, so the UI showed raw keys like nav_servers.
2026-07-27 04:40:44 +03:00
orohi 80160d0ef4 Fix Cascade egress verify to bind awg0 IP (v2.2.1).
Use client-subnet policy routing for curl checks instead of cascade peer Address; soft-warn when echo services fail but the route is OK.
2026-07-27 04:27:00 +03:00
orohi ac76a0e540 Release v2.2.0: restore safe Cascade double-VPN for servers.
Reintroduce entry-to-exit AmneziaWG/WireGuard cascade inspired by ryderams/amneziawg-cascade, with handshake and egress checks and no remote curl|bash.
2026-07-27 04:09:16 +03:00
orohi bf7bd16fcd Release v2.1.0: mark NaiveProxy stable with client notes.
Stable NaiveProxy; note that v2rayN must not be used, Karing is confirmed, other clients are untested.
2026-07-26 11:23:10 +03:00
orohiandCursor 24e793d943 Add NaiveProxy client hint for v2rayN IPv6 DNS failures.
Show guidance when copying config if latency fails due to Google DNS over IPv6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:31:41 +03:00
orohiandCursor 415cfea5eb Fix NaiveProxy share links to always include port 443.
v2rayN treated links without an explicit port as invalid/80 and failed TLS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:26:37 +03:00
orohiandCursor 04a32fb168 Release v2.0.0-beta: add NaiveProxy (Caddy + klzgrad/forwardproxy).
Installable marketplace protocol with ACME TLS, per-client basic auth, naive+https share links; bump panel version to 2.0 Beta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:06:16 +03:00
orohiandCursor 183071f588 Wire Hysteria 2 UI with per-server SSL defaults and renew.
Expose domain/email on servers, warn that TCP 80/443 must be free on install, and allow Let's Encrypt re-issue from Hysteria settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:46:20 +03:00
orohiandCursor ed5c54cf96 Add Hysteria 2 protocol manager (apernet/hysteria).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:25:02 +03:00
20 changed files with 6842 additions and 2725 deletions
+67 -5
View File
@@ -1,6 +1,6 @@
# Amnezia Web Panel
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **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
>
@@ -62,8 +62,11 @@ Configuration panel for system parameters and preferences:
* **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients.
* **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support.
* **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation.
* **Hysteria 2**: QUIC/HTTP3 proxy from [apernet/hysteria](https://github.com/apernet/hysteria) via `tobyxdd/hysteria:v2` — Let's Encrypt TLS, salamander obfuscation, password auth, `hy2://` share links. Per-server SSL domain/email defaults, renew from settings; install requires free TCP **80** and **443**.
* **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.
@@ -71,17 +74,23 @@ Configuration panel for system parameters and preferences:
* **NGINX + Let's Encrypt**: Reverse-proxy and HTTPS automation with certificate management for secure public endpoints.
* **⚙️ Core Server Management**:
* **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically.
* **Per-server SSL defaults**: optional domain + Let's Encrypt email on each server, pre-filled when installing Hysteria or NGINX.
* **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers.
* **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol.
* **Reboot** the server directly from the UI.
* Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback.
* **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang.
* **🧩 Marketplace & Templates**:
* Market templates provide quick presets for installing and configuring supported protocols and services.
* Market templates provide quick presets for installing and configuring supported protocols and services (including **Hysteria 2**).
* Multi-protocol management lets you run and control multiple protocol instances on the same server.
* **🌐 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.
@@ -219,21 +228,72 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### v2.5.4
* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links).
### v2.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).
### v2.2.1
* **Cascade egress verify fix**: bind curl to ENTRY `awg0`/`wg0` IP (client-subnet policy route), not cascade peer Address.
* Soft-warn (no rollback) when policy route via `cascade` is OK but public-IP echo services fail.
### v2.2.0
* **Cascade (double VPN) restored** — later removed in **v2.3.0**.
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic ran over panel SSH (**no remote curl|bash**).
### v2.1.0
* **NaiveProxy — stable**: production-ready install (Caddy + klzgrad/forwardproxy), share links always include `:443`.
* **Client notes (important)**:
* **Do not use v2rayN** with NaiveProxy (broken DNS/IPv6 behaviour → latency 1 ms even when the server is fine).
* **Karing** — confirmed working.
* Other clients — untested / use at your own risk.
* Share-link and config UI hints updated accordingly.
### v2.0.0-beta
* **NaiveProxy** (Beta): install [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) server via Caddy + naïve [forwardproxy](https://github.com/klzgrad/forwardproxy) — domain TLS (ACME), per-client basic auth, `naive+https://` share links.
* Marketplace / server page / users / invites / Telegram / backups include NaiveProxy.
* Install warns that free TCP **80** and **443** are required; uses per-server SSL domain/email defaults when set.
* Official prebuilt Caddy on amd64; xcaddy build path for other arches.
* Version line bumped to **2.0 Beta** (`v2.0.0-beta`).
### v1.8.0
* **Hysteria 2** fully wired in marketplace, server page, users/invites, Telegram bot, and backups.
* **Per-server SSL defaults**: set domain + Let's Encrypt email when adding/editing a server; values pre-fill Hysteria (and NGINX) install forms.
* **SSL renew**: change domain or use “Renew SSL” in Hysteria settings to re-issue the certificate (needs free TCP 80).
* **Install warning**: free TCP ports **80** and **443** required for stable Let's Encrypt / HTTPS-QUIC operation.
* Official image `tobyxdd/hysteria:v2`, salamander obfs, password auth, `hy2://` share links, selectable UDP listen port.
### v1.7.0
* **Clearer create flow**: pick **server first**, then **protocol** (WireGuard, AmneziaWG 2.0, …) — invites, guest access, and user connections no longer dump everything into one messy list.
* Protocol titles are human-readable and ordered (AWG 2.0 → AWG → Legacy → WireGuard → Xray → Telemt).
* Protocol titles are human-readable and ordered (AWG 2.0 → AWG → Legacy → WireGuard → Xray → Telemt → Hysteria).
* 3x-ui is a separate “server” choice; VLESS inbounds still load from that panels API.
* **Hysteria 2** manager restored ([apernet/hysteria](https://github.com/apernet/hysteria)) — see **v1.8.0** for full panel integration.
### v1.6.0
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
* **Cascade removed** for now (pending redesign — showed active while internet often did not work).
* **Cascade** was temporarily removed in this release (briefly restored in v2.2.x, removed again in **v2.3.0**).
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
* **API performance**: in-memory data cache, faster server check/stats.
* **Share / guest**: one-tap “Copy key”.
* **User expiration**: countdown can start on first config use; UTC-safe comparisons.
* **UI**: shared SVG icon system.
* **Removed earlier**: Hysteria 2.
## 🔧 Project Details
@@ -295,6 +355,8 @@ web-panel/
│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy
│ ├── wireguard_manager.py # Classic WireGuard
│ ├── xray_manager.py # Xray-core (VLESS-Reality)
│ ├── hysteria_manager.py # Hysteria 2 (apernet/hysteria)
│ ├── naiveproxy_manager.py # NaiveProxy (Caddy + klzgrad/forwardproxy)
│ ├── telemt_manager.py # Telegram MTProxy
│ ├── dns_manager.py # AmneziaDNS (Unbound)
│ ├── adguard_manager.py # AdGuard Home
+746 -16
View File
@@ -46,6 +46,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,9 +102,9 @@ 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 = "v1.7.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.5.4"
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'))
@@ -137,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'
@@ -151,7 +153,7 @@ def load_translations():
if f.endswith('.json'):
lang = f.split('.')[0]
try:
with open(os.path.join(trans_dir, f), 'r', encoding='utf-8') as tf:
with open(os.path.join(trans_dir, f), 'r', encoding='utf-8-sig') as tf:
TRANSLATIONS[lang] = json.load(tf)
except Exception as e:
logger.error(f"Error loading translation {f}: {e}")
@@ -372,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'
@@ -843,8 +991,8 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20):
return get_tunnel_status(provider)
BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'}
BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'}
def protocol_base(protocol: str) -> str:
@@ -884,6 +1032,8 @@ def protocol_display_name(protocol: str) -> str:
'awg_legacy': 'AmneziaWG Legacy',
'xray': 'Xray',
'telemt': 'Telemt',
'hysteria': 'Hysteria 2',
'naiveproxy': 'NaiveProxy',
'dns': 'AmneziaDNS',
'wireguard': 'WireGuard',
'socks5': 'SOCKS5',
@@ -903,6 +1053,8 @@ def protocol_container_name(protocol: str) -> Optional[str]:
'awg_legacy': 'amnezia-awg-legacy',
'xray': 'amnezia-xray',
'telemt': 'telemt',
'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy',
'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy',
@@ -942,6 +1094,12 @@ def get_protocol_manager(ssh, protocol: str):
elif base == 'nginx':
from managers.nginx_manager import NginxManager
return NginxManager(ssh, protocol)
elif base == 'hysteria':
from managers.hysteria_manager import HysteriaManager
return HysteriaManager(ssh, protocol)
elif base == 'naiveproxy':
from managers.naiveproxy_manager import NaiveProxyManager
return NaiveProxyManager(ssh, protocol)
from managers.awg_manager import AWGManager
return AWGManager(ssh)
@@ -1003,7 +1161,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
# Protocols that own VPN clients (can list/link connections)
CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'xui'}
CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'naiveproxy', 'xui'}
def generate_vpn_link(config_text):
@@ -1153,6 +1311,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.
@@ -1665,6 +2003,8 @@ class AddServerRequest(BaseModel):
password: str = ''
private_key: str = ''
name: str = ''
ssl_domain: str = ''
ssl_email: str = ''
class EditServerRequest(BaseModel):
@@ -1677,6 +2017,8 @@ class EditServerRequest(BaseModel):
# fields can be omitted to keep current auth unchanged.
password: Optional[str] = None
private_key: Optional[str] = None
ssl_domain: Optional[str] = None
ssl_email: Optional[str] = None
class ReorderServersRequest(BaseModel):
@@ -1706,6 +2048,12 @@ class InstallProtocolRequest(BaseModel):
# NGINX
nginx_domain: Optional[str] = None
nginx_email: Optional[str] = None
# Hysteria
hysteria_domain: Optional[str] = None
hysteria_email: Optional[str] = None
# NaiveProxy
naiveproxy_domain: Optional[str] = None
naiveproxy_email: Optional[str] = None
class Socks5SettingsRequest(BaseModel):
@@ -1715,6 +2063,13 @@ class Socks5SettingsRequest(BaseModel):
password: Optional[str] = None
class HysteriaSettingsRequest(BaseModel):
protocol: str = 'hysteria'
port: Optional[int] = None
domain: Optional[str] = None
email: Optional[str] = None
renew_ssl: bool = False
class ProtocolRequest(BaseModel):
protocol: str = 'awg'
@@ -1754,10 +2109,12 @@ class ConnectionActionRequest(BaseModel):
client_id: str = ''
class ToggleConnectionRequest(BaseModel):
class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
enable: bool = True
target_server_id: int
client_ids: List[str]
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel):
@@ -1965,6 +2322,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")
@@ -2272,7 +2639,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, all_servers=data['servers'])
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"])
@@ -2433,6 +2815,13 @@ async def api_add_server(request: Request, req: AddServerRequest):
'private_key': req.private_key, 'server_info': server_info,
'protocols': {},
}
ssl_domain = (req.ssl_domain or '').strip().lower()
ssl_email = (req.ssl_email or '').strip()
if ssl_domain:
server_info['ssl_domain'] = ssl_domain
if ssl_email:
server_info['ssl_email'] = ssl_email
server['server_info'] = server_info
data = load_data()
data['servers'].append(server)
save_data(data)
@@ -2493,9 +2882,27 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
server['username'] = new_user
server['password'] = new_pass
server['private_key'] = new_key
server['server_info'] = server_info
# Merge SSH probe info with preserved SSL domain defaults
info = dict(server.get('server_info') or {})
if isinstance(server_info, dict):
for k, v in server_info.items():
if k not in ('ssl_domain', 'ssl_email'):
info[k] = v
if req.ssl_domain is not None:
domain = (req.ssl_domain or '').strip().lower()
if domain:
info['ssl_domain'] = domain
else:
info.pop('ssl_domain', None)
if req.ssl_email is not None:
email = (req.ssl_email or '').strip()
if email:
info['ssl_email'] = email
else:
info.pop('ssl_email', None)
server['server_info'] = info
save_data(data)
return {'status': 'success', 'server_info': server_info}
return {'status': 'success', 'server_info': info}
except Exception as e:
logger.exception("Error editing server")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -2801,6 +3208,18 @@ async def api_check_server(request: Request, server_id: int):
for key in ('domain', 'email', 'site_url'):
if db_proto.get(key) not in (None, ''):
merged[key] = db_proto[key]
if protocol_base(proto) == 'hysteria':
for key in ('domain', 'email'):
if db_proto.get(key) not in (None, '') and not merged.get(key):
merged[key] = db_proto[key]
if db_proto.get('port') and not merged.get('port'):
merged['port'] = db_proto['port']
if protocol_base(proto) == 'naiveproxy':
for key in ('domain', 'email'):
if db_proto.get(key) not in (None, '') and not merged.get(key):
merged[key] = db_proto[key]
if db_proto.get('port') and not merged.get('port'):
merged['port'] = db_proto['port']
return merged
def should_preserve_saved_protocol(proto, result=None, err=None):
@@ -2952,6 +3371,20 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
domain=req.nginx_domain,
email=req.nginx_email,
)
elif install_base == 'hysteria':
result = manager.install_protocol(
protocol_type=install_protocol,
port=req.port,
domain=req.hysteria_domain,
email=req.hysteria_email,
)
elif install_base == 'naiveproxy':
result = manager.install_protocol(
protocol_type=install_protocol,
port=req.port,
domain=req.naiveproxy_domain,
email=req.naiveproxy_email,
)
else:
result = manager.install_protocol(install_protocol, port=req.port)
@@ -2978,6 +3411,29 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
proto_record['domain'] = result.get('domain')
proto_record['email'] = result.get('email')
proto_record['site_url'] = result.get('site_url')
if install_base == 'hysteria':
# Remember domain/email as server defaults for next installs / renewals
info = server.setdefault('server_info', {})
if req.hysteria_domain:
info['ssl_domain'] = (req.hysteria_domain or '').strip().lower()
if req.hysteria_email:
info['ssl_email'] = (req.hysteria_email or '').strip()
save_data(data)
proto_record['domain'] = result.get('domain')
proto_record['email'] = result.get('email')
if result.get('port'):
proto_record['port'] = str(result['port'])
if install_base == 'naiveproxy':
info = server.setdefault('server_info', {})
if req.naiveproxy_domain:
info['ssl_domain'] = (req.naiveproxy_domain or '').strip().lower()
if req.naiveproxy_email:
info['ssl_email'] = (req.naiveproxy_email or '').strip()
save_data(data)
proto_record['domain'] = result.get('domain')
proto_record['email'] = result.get('email')
if result.get('port'):
proto_record['port'] = str(result['port'])
proto_record['base_protocol'] = install_base
proto_record['instance'] = protocol_instance(install_protocol)
proto_record['display_name'] = protocol_display_name(install_protocol)
@@ -3053,6 +3509,83 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S
return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"])
async def api_hysteria_get_settings(request: Request, server_id: int, protocol: str = 'hysteria'):
"""Return current Hysteria domain/port for the settings modal."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
if not is_valid_protocol(protocol) or protocol_base(protocol) != 'hysteria':
return JSONResponse({'error': 'Invalid protocol'}, status_code=400)
server = data['servers'][server_id]
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, protocol)
settings = manager.get_settings()
ssh.disconnect()
saved = (server.get('protocols') or {}).get(protocol) or {}
if not settings.get('port') and saved.get('port'):
settings['port'] = int(saved['port'])
return {'status': 'success', **settings}
except Exception as e:
logger.exception("Error reading Hysteria settings")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"])
async def api_hysteria_update_settings(request: Request, server_id: int, req: HysteriaSettingsRequest):
"""Change Hysteria UDP listen port and recreate the container."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
protocol = req.protocol if is_valid_protocol(req.protocol) and protocol_base(req.protocol) == 'hysteria' else 'hysteria'
server = data['servers'][server_id]
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, protocol)
result = manager.update_settings(
port=req.port,
domain=req.domain,
email=req.email,
renew_ssl=bool(req.renew_ssl),
)
ssh.disconnect()
if result.get('status') == 'error':
return JSONResponse(
{'error': result.get('message') or 'Failed to update settings', **{k: v for k, v in result.items() if k != 'status'}},
status_code=400,
)
if result.get('port') or result.get('domain'):
srv_proto = server.setdefault('protocols', {}).setdefault(protocol, {})
if result.get('port'):
srv_proto['port'] = str(result['port'])
srv_proto['installed'] = True
srv_proto['base_protocol'] = protocol_base(protocol)
srv_proto['instance'] = protocol_instance(protocol)
srv_proto['display_name'] = protocol_display_name(protocol)
srv_proto['container_name'] = protocol_container_name(protocol)
if result.get('domain'):
srv_proto['domain'] = result['domain']
# Keep server-level SSL defaults in sync
info = server.setdefault('server_info', {})
if result.get('domain'):
info['ssl_domain'] = result['domain']
if result.get('email'):
info['ssl_email'] = result['email']
save_data(data)
return result
except Exception as e:
logger.exception("Error updating Hysteria settings")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"])
async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest):
if not _check_admin(request):
@@ -3086,6 +3619,8 @@ CONTAINER_NAMES = {
'awg_legacy': 'amnezia-awg-legacy',
'xray': 'amnezia-xray',
'telemt': 'telemt',
'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy',
'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy',
@@ -3392,13 +3927,39 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
ssh.run_sudo_command(f"docker stop {container}")
action = 'stopped'
else:
ssh.run_sudo_command(f"docker start {container}")
if protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol)
meta = mgr._read_metadata()
port = int(meta.get('port') or mgr.DEFAULT_PORT)
mgr._write_config_from_clients(port)
mgr._start_container(port)
else:
ssh.run_sudo_command(f"docker start {container}")
action = 'started'
error = ''
recent_logs = ''
if protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol)
diag = mgr.get_container_diagnostics(req.protocol)
error = diag.get('error_summary') or ''
recent_logs = diag.get('recent_logs') or ''
if action == 'started' and not diag.get('running'):
ssh.disconnect()
return JSONResponse({
'error': error or 'Hysteria failed to start',
'action': action,
'container': container,
'recent_logs': recent_logs,
}, status_code=400)
ssh.disconnect()
return {
'status': 'success',
'action': action,
'container': container,
'error': error,
'recent_logs': recent_logs,
}
except Exception as e:
logger.exception("Error toggling container")
@@ -3420,7 +3981,22 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog
container = protocol_container_name(req.protocol)
ssh = get_ssh(server)
ssh.connect()
# Generic docker logs for all protocols
if protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol)
logs = mgr.get_logs(req.protocol, tail=req.tail or 200)
diag = mgr.get_container_diagnostics(req.protocol)
ssh.disconnect()
return {
'status': 'success',
'protocol': req.protocol,
'container': container,
'logs': logs,
'running': bool(diag.get('running')),
'error': diag.get('error_summary') or '',
'exit_code': diag.get('exit_code'),
'container_status': diag.get('status') or '',
}
tail = max(20, min(int(req.tail or 200), 2000))
out, err, _ = ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
@@ -3541,6 +4117,14 @@ async def api_server_config(request: Request, server_id: int, req: ProtocolReque
from managers.nginx_manager import NginxManager
mgr = NginxManager(ssh, req.protocol)
config = mgr._get_server_config(req.protocol)
elif protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol)
config = mgr.get_server_config(req.protocol)
elif protocol_base(req.protocol) == 'naiveproxy':
from managers.naiveproxy_manager import NaiveProxyManager
mgr = NaiveProxyManager(ssh, req.protocol)
config = mgr.get_server_config(req.protocol)
else:
mgr = AWGManager(ssh)
config = mgr._get_server_config(req.protocol)
@@ -3585,6 +4169,14 @@ async def api_server_config_save(request: Request, server_id: int, req: ServerCo
from managers.nginx_manager import NginxManager
mgr = NginxManager(ssh, req.protocol)
mgr.save_server_config(req.protocol, req.config)
elif protocol_base(req.protocol) == 'hysteria':
from managers.hysteria_manager import HysteriaManager
mgr = HysteriaManager(ssh, req.protocol)
mgr.save_server_config(req.protocol, req.config)
elif protocol_base(req.protocol) == 'naiveproxy':
from managers.naiveproxy_manager import NaiveProxyManager
mgr = NaiveProxyManager(ssh, req.protocol)
mgr.save_server_config(req.protocol, req.config)
else:
mgr = AWGManager(ssh)
mgr.save_server_config(req.protocol, req.config)
@@ -3877,6 +4469,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):
@@ -5261,6 +5882,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,
@@ -5274,20 +5897,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(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):
@@ -5297,6 +6004,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(),
}
@@ -5382,6 +6090,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)
+8
View File
@@ -62,6 +62,14 @@ class BackupManager:
remote_dir = inst_path('/opt/amnezia/telemt')
paths['host'] = [remote_dir]
paths['container'] = [remote_dir]
elif base == 'hysteria':
remote_dir = inst_path('/opt/amnezia/hysteria')
paths['host'] = [remote_dir]
paths['container'] = ['/etc/hysteria']
elif base == 'naiveproxy':
remote_dir = inst_path('/opt/amnezia/naiveproxy')
paths['host'] = [remote_dir]
paths['container'] = ['/etc/caddy']
elif base == 'dns':
paths['host'] = ['/opt/amnezia/dns']
paths['container'] = ['/opt/amnezia/dns']
+744
View File
@@ -0,0 +1,744 @@
"""
Hysteria 2 protocol manager official tobyxdd/hysteria image (apernet/hysteria).
Installs Hysteria with Let's Encrypt TLS for an admin-provided domain
(certbot standalone on port 80), host networking, BBR, and salamander obfs.
Clients use password auth (Happ/INCY-compatible); share links are hy2:// URIs.
"""
from __future__ import annotations
import json
import logging
import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
def _q(value):
return shlex.quote(str(value))
def _rand_token(length=16):
alphabet = string.ascii_lowercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
class HysteriaManager:
PROTOCOL = 'hysteria'
CONTAINER_NAME = 'amnezia-hysteria'
IMAGE_NAME = 'tobyxdd/hysteria:v2'
CERTBOT_IMAGE = 'certbot/certbot:latest'
BASE_DIR = '/opt/amnezia/hysteria'
DEFAULT_PORT = 8998
def __init__(self, ssh, protocol='hysteria'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.instance = self._instance_index(self.protocol)
self.container_name = self._container_name(self.protocol)
self.base_dir = self._base_dir(self.protocol)
self.config_path = f'{self.base_dir}/server.yaml'
self.clients_path = f'{self.base_dir}/clients.json'
self.meta_path = f'{self.base_dir}/metadata.json'
self.cert_path = f'{self.base_dir}/cert.crt'
self.key_path = f'{self.base_dir}/private.key'
self.letsencrypt_dir = f'{self.base_dir}/letsencrypt'
def _instance_index(self, protocol=None):
parts = str(protocol or self.protocol or '').split('__', 1)
if len(parts) == 2:
try:
return max(1, int(parts[1]))
except ValueError:
return 1
return 1
def _container_name(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}'
def _base_dir(self, protocol=None):
idx = self._instance_index(protocol or self.protocol)
return self.BASE_DIR if idx <= 1 else f'{self.BASE_DIR}-{idx}'
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def check_protocol_installed(self, protocol_type=None):
name = self._container_name(protocol_type or self.protocol)
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{name}$ --format '{{{{.Names}}}}'"
)
return name in out.strip().split('\n')
def check_container_running(self, protocol_type=None):
name = self._container_name(protocol_type or self.protocol)
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{name}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_logs(self, protocol_type=None, tail=200):
"""Return recent docker logs for the Hysteria container."""
name = self._container_name(protocol_type or self.protocol)
tail = max(20, min(int(tail or 200), 2000))
out, err, code = self.ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {name} 2>&1",
timeout=30,
)
text = (out or err or '').strip()
if code != 0 and not text:
text = f'(no logs: exit {code})'
return text
def get_container_diagnostics(self, protocol_type=None):
"""Inspect why the container is stopped / unhealthy."""
name = self._container_name(protocol_type or self.protocol)
fmt = (
'{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|'
'{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|'
'{{.State.StartedAt}}'
)
out, _, code = self.ssh.run_sudo_command(
f"docker inspect -f '{fmt}' {name} 2>/dev/null"
)
diag = {
'status': 'unknown',
'running': False,
'exit_code': None,
'oom_killed': False,
'error': '',
'finished_at': '',
'started_at': '',
'recent_logs': '',
'error_summary': '',
}
if code != 0 or not (out or '').strip():
diag['status'] = 'missing'
diag['error_summary'] = 'Container not found'
return diag
parts = (out or '').strip().split('|')
while len(parts) < 7:
parts.append('')
status, running, exit_code, oom, err, finished, started = parts[:7]
diag['status'] = status or 'unknown'
diag['running'] = str(running).lower() == 'true'
try:
diag['exit_code'] = int(exit_code)
except Exception:
diag['exit_code'] = None
diag['oom_killed'] = str(oom).lower() == 'true'
diag['error'] = (err or '').strip()
diag['finished_at'] = (finished or '').strip()
diag['started_at'] = (started or '').strip()
diag['recent_logs'] = self.get_logs(protocol_type, tail=40)
# Build human-readable summary for the UI badge
if diag['running']:
diag['error_summary'] = ''
elif diag['oom_killed']:
diag['error_summary'] = 'OOM killed (out of memory)'
elif diag['error']:
diag['error_summary'] = diag['error']
elif diag['exit_code'] not in (None, 0):
# Prefer bind/port conflicts over generic FATAL lines
bind_line = ''
last = ''
for raw in reversed((diag['recent_logs'] or '').splitlines()):
line = self._strip_ansi(raw).strip()
if not line:
continue
# strip docker --timestamps prefix
m_ts = re.match(r'^\d{4}-\d{2}-\d{2}T\S+\s+(.*)$', line)
if m_ts:
line = m_ts.group(1).strip()
low = line.lower()
if 'address already in use' in low and not bind_line:
bind_line = line
if ('FATAL' in line or 'failed to load' in low) and not last:
last = line
if not last:
last = line
pick = bind_line or last
if pick and 'address already in use' in pick.lower():
m = re.search(r'listen udp :(\d+)', pick, re.I) or re.search(r':(\d+):\s*bind', pick)
p = m.group(1) if m else '?'
if p == '?':
m2 = re.search(r'udp\s*:(\d+)', pick, re.I)
p = m2.group(1) if m2 else '?'
diag['error_summary'] = (
f'UDP port {p} already in use — change port below (try 8998 or 8443)'
)
diag['port_busy'] = True
elif pick:
short = re.sub(r'\s+', ' ', pick)[:160]
diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
else:
diag['error_summary'] = f"exited with code {diag['exit_code']}"
elif diag['status'] and diag['status'] != 'running':
diag['error_summary'] = f"status: {diag['status']}"
return diag
def get_server_status(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type)
meta = self._read_metadata() if exists else {}
clients = self._read_clients() if exists else []
diag = {}
# Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs)
if exists:
try:
name = self._container_name(protocol_type)
mode, _, _ = self.ssh.run_sudo_command(
f"docker inspect -f '{{{{.HostConfig.NetworkMode}}}}' {name} 2>/dev/null"
)
mode = (mode or '').strip()
cfg = self._read_file(self.config_path)
needs_cfg = (
'type: userpass' in cfg
or 'type: salamander' not in cfg
or 'type: password' not in cfg
)
if (mode and mode != 'host') or needs_cfg:
port = int(meta.get('port') or self.DEFAULT_PORT)
self._write_config_from_clients(port)
self._start_container(port)
running = self.check_container_running(protocol_type)
meta = self._read_metadata()
except Exception as e:
logger.warning('Hysteria migrate failed: %s', e)
diag = {'error_summary': f'migrate failed: {e}'}
if exists:
try:
diag = self.get_container_diagnostics(protocol_type)
running = bool(diag.get('running'))
except Exception as e:
logger.warning('Hysteria diagnostics failed: %s', e)
diag = {'error_summary': str(e)}
return {
'container_exists': exists,
'container_running': running,
'port': int(meta.get('port') or self.DEFAULT_PORT),
'domain': meta.get('domain'),
'email': meta.get('email'),
'clients_count': len(clients),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': self._instance_index(protocol_type),
'container_name': self._container_name(protocol_type),
'error': diag.get('error_summary') or '',
'exit_code': diag.get('exit_code'),
'container_status': diag.get('status') or '',
'recent_logs': diag.get('recent_logs') or '',
'port_busy': bool(diag.get('port_busy')),
}
# ===================== HELPERS =====================
def _validate_domain(self, domain):
domain = (domain or '').strip().lower()
if not domain or len(domain) > 253:
raise ValueError('Domain is required for Hysteria SSL')
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError("Valid Let's Encrypt email is required")
return email
def _read_file(self, path):
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
return out if code == 0 else ''
def _write_file(self, path, content):
parent = path.rsplit('/', 1)[0]
self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}")
self.ssh.upload_file_sudo(content, path)
def _read_metadata(self):
raw = self._read_file(self.meta_path)
if not raw.strip():
return {}
try:
return json.loads(raw)
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.meta_path, json.dumps(meta, indent=2, ensure_ascii=False))
def _read_clients(self):
raw = self._read_file(self.clients_path)
if not raw.strip():
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except Exception:
return []
def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2, ensure_ascii=False))
def _ensure_secrets(self, meta):
"""Password auth (Happ/INCY-friendly) + salamander obfs secrets."""
changed = False
if not meta.get('password'):
meta['password'] = _rand_token(24)
changed = True
if not meta.get('obfs_password'):
meta['obfs_password'] = _rand_token(16)
changed = True
if changed:
self._write_metadata(meta)
return meta
def _build_server_yaml(self, port, clients, meta=None):
meta = self._ensure_secrets(meta if meta is not None else self._read_metadata())
password = meta['password']
obfs_password = meta['obfs_password']
# password auth — Happ / sing-box / INCY expect a single auth string, not userpass
lines = [
f'listen: :{int(port)}',
'',
'tls:',
' cert: /etc/hysteria/cert.crt',
' key: /etc/hysteria/private.key',
'',
'auth:',
' type: password',
f' password: "{password}"',
'',
'obfs:',
' type: salamander',
' salamander:',
f' password: "{obfs_password}"',
'',
'quic:',
' initStreamReceiveWindow: 8388608',
' maxStreamReceiveWindow: 8388608',
' initConnReceiveWindow: 20971520',
' maxConnReceiveWindow: 20971520',
' maxIdleTimeout: 60s',
' maxIncomingStreams: 1024',
' disablePathMTUDiscovery: true',
'',
'ignoreClientBandwidth: true',
'',
'bandwidth:',
' up: 1 gbps',
' down: 1 gbps',
'',
'masquerade:',
' type: proxy',
' proxy:',
' url: https://www.bing.com',
' rewriteHost: true',
'',
'outbounds:',
' - name: direct',
' type: direct',
'',
]
return '\n'.join(lines)
def _write_config_from_clients(self, port=None):
meta = self._ensure_secrets(self._read_metadata())
port = int(port or meta.get('port') or self.DEFAULT_PORT)
# clients list is panel-side only (shared password); still rewrite yaml for port/obfs/secrets
self._write_file(self.config_path, self._build_server_yaml(port, self._read_clients(), meta))
return port
def _tune_host_network(self):
"""UDP buffers + BBR — reduces quic-go EOF and improves throughput."""
script = r"""
set +e
mkdir -p /etc/sysctl.d
printf '%s\n' \
'net.core.rmem_max=16777216' \
'net.core.wmem_max=16777216' \
'net.core.rmem_default=16777216' \
'net.core.wmem_default=16777216' \
'net.core.default_qdisc=fq' \
'net.ipv4.tcp_congestion_control=bbr' \
'net.ipv4.ip_forward=1' \
> /etc/sysctl.d/99-amnezia-hysteria.conf
modprobe tcp_bbr >/dev/null 2>&1 || true
sysctl -p /etc/sysctl.d/99-amnezia-hysteria.conf >/dev/null 2>&1 || true
sysctl -w net.core.rmem_max=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.wmem_max=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.rmem_default=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.wmem_default=16777216 >/dev/null 2>&1 || true
sysctl -w net.core.default_qdisc=fq >/dev/null 2>&1 || true
sysctl -w net.ipv4.tcp_congestion_control=bbr >/dev/null 2>&1 || true
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
"""
self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
def _strip_ansi(self, text):
return re.sub(r'\x1b\[[0-9;]*m', '', text or '')
def _udp_port_busy(self, port):
"""Return (busy: bool, detail: str) for host UDP port (after our container is removed)."""
port = int(port)
out, _, _ = self.ssh.run_sudo_command(
f"ss -ulnp 'sport = :{port}' 2>/dev/null || "
f"ss -uln | grep -E ':{port}([[:space:]]|$)' || true"
)
detail = (out or '').strip()
return bool(detail), detail[:240]
def _open_udp_port(self, port):
port = int(port)
script = f"""
set +e
if command -v ufw >/dev/null 2>&1; then
ufw allow {port}/udp >/dev/null 2>&1 || true
fi
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --permanent --add-port={port}/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || true
"""
self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
def _start_container(self, port):
"""Run with --network host so QUIC/UDP is not broken by Docker NAT (EOF)."""
name = self.container_name
port = int(port)
if port == 80:
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt validation')
self._tune_host_network()
self._open_udp_port(port)
self.ssh.run_sudo_command(
f"chmod 644 {_q(self.cert_path)} {_q(self.key_path)} 2>/dev/null || true"
)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
busy, detail = self._udp_port_busy(port)
if busy:
raise RuntimeError(
f'UDP port {port} is already in use — choose another port in Hysteria settings. '
f'({self._strip_ansi(detail)})'
)
run_cmd = (
f"docker run -d --restart always "
f"--name {name} "
f"--network host "
f"--cap-add=NET_ADMIN "
f"-v {_q(self.base_dir)}:/etc/hysteria "
f"{self.IMAGE_NAME} "
f"-c /etc/hysteria/server.yaml server"
)
_, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60)
if code != 0:
raise RuntimeError(f'Failed to start Hysteria: {err}')
# Confirm it stayed up (catch immediate bind failures)
time.sleep(1.2)
if not self.check_container_running(self.protocol):
diag = self.get_container_diagnostics(self.protocol)
raise RuntimeError(
diag.get('error_summary')
or 'Hysteria container exited immediately — check logs / port'
)
return True
def get_settings(self):
meta = self._ensure_secrets(self._read_metadata())
port = int(meta.get('port') or self.DEFAULT_PORT)
return {
'port': port,
'suggested_port': 8998 if port in (443, 80) else port,
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'protocol': self.protocol,
}
def update_settings(self, port=None, domain=None, email=None, renew_ssl=False):
"""Change listen UDP port and/or re-issue Let's Encrypt for domain."""
meta = self._ensure_secrets(self._read_metadata())
if port is None:
port = meta.get('port') or self.DEFAULT_PORT
port = int(port)
if port < 1 or port > 65535:
return {'status': 'error', 'message': 'Invalid port'}
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
old_domain = (meta.get('domain') or '').strip().lower()
domain_changed = False
try:
if domain is not None and str(domain).strip():
new_domain = self._validate_domain(domain)
domain_changed = new_domain != old_domain
meta['domain'] = new_domain
if email is not None and str(email).strip():
meta['email'] = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
if renew_ssl or domain_changed:
# Domain change or explicit renew → re-issue certificate (needs free TCP 80)
try:
d = meta.get('domain') or ''
e = meta.get('email') or ''
if not d or not e:
return {
'status': 'error',
'message': 'Domain and email are required to issue SSL',
}
self._issue_certificate(d, e)
except Exception as exc:
return {'status': 'error', 'message': str(exc)}
meta['port'] = port
self._write_metadata(meta)
self._write_config_from_clients(port)
try:
self._start_container(port)
except Exception as e:
return {
'status': 'error',
'message': str(e),
'port': str(port),
'domain': meta.get('domain'),
'email': meta.get('email'),
}
msg = 'Hysteria settings updated'
if renew_ssl or domain_changed:
msg = 'SSL certificate issued and Hysteria settings updated'
return {
'status': 'success',
'port': str(port),
'domain': meta.get('domain'),
'email': meta.get('email'),
'message': msg,
}
def _reload_container(self):
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)
try:
self._start_container(port)
except Exception as e:
logger.warning('Hysteria container reload failed: %s', e)
def _issue_certificate(self, domain, email):
"""Issue Let's Encrypt cert via certbot standalone (needs free TCP 80)."""
self.ssh.run_sudo_command(f"mkdir -p {_q(self.letsencrypt_dir)}")
self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180)
# Stop anything briefly occupying 80 if it's our leftover certbot
self.ssh.run_sudo_command(
"docker rm -fv amnezia-hysteria-certbot 2>/dev/null || true"
)
cmd = (
f"docker run --rm --name amnezia-hysteria-certbot "
f"-p 80:80 "
f"-v {_q(self.letsencrypt_dir)}:/etc/letsencrypt "
f"{self.CERTBOT_IMAGE} certonly --standalone "
f"--non-interactive --agree-tos --no-eff-email "
f"--email {_q(email)} -d {_q(domain)}"
)
out, err, code = self.ssh.run_sudo_command(cmd, timeout=300)
if code != 0:
raise RuntimeError(
f"Let's Encrypt failed for {domain}. "
f"Point A-record to this server and free TCP port 80. {err or out}"
)
# Copy live certs into paths expected by the server config
copy_script = f"""
set -e
LIVE={_q(self.letsencrypt_dir)}/live/{_q(domain)}
test -s "$LIVE/fullchain.pem"
test -s "$LIVE/privkey.pem"
cp -f "$LIVE/fullchain.pem" {_q(self.cert_path)}
cp -f "$LIVE/privkey.pem" {_q(self.key_path)}
chmod 644 {_q(self.cert_path)} {_q(self.key_path)}
"""
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to install certificate files: {err or out}')
def _build_share_uri(self, host, port, domain, name, meta=None):
"""Happ/INCY-compatible hy2 link: password auth + salamander + IP host + SNI."""
meta = self._ensure_secrets(meta if meta is not None else self._read_metadata())
password = meta['password']
obfs_password = meta['obfs_password']
sni = domain or host
# Prefer raw IP in URI — domain may be CDN/DNS filtered for UDP while ICMP still works
conn_host = host or domain
fragment = quote(name or 'hysteria', safe='')
return (
f"hy2://{quote(password, safe='')}@{conn_host}:{int(port)}/"
f"?sni={quote(sni, safe='')}"
f"&obfs=salamander"
f"&obfs-password={quote(obfs_password, safe='')}"
f"&insecure=0#{fragment}"
)
# ===================== INSTALL / REMOVE =====================
def install_protocol(self, protocol_type=None, port=None, domain=None, email=None):
protocol_type = protocol_type or self.protocol
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
domain = self._validate_domain(domain)
email = self._validate_email(email)
port = int(port or self.DEFAULT_PORT)
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
log = []
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180)
log.append(f'Pulled {self.IMAGE_NAME}')
if self.check_protocol_installed(protocol_type):
name = self._container_name(protocol_type)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
log.append('Removed previous container')
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
self._write_clients([])
meta = {
'domain': domain,
'email': email,
'port': port,
'password': _rand_token(24),
'obfs_password': _rand_token(16),
}
self._write_metadata(meta)
self._write_config_from_clients(port)
log.append('Prepared config directory (password auth + salamander + BBR)')
try:
self._issue_certificate(domain, email)
log.append(f"Issued Let's Encrypt certificate for {domain}")
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
try:
self._start_container(port)
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append(f'Started {self.container_name} on UDP {port} (host network, BBR)')
return {
'status': 'success',
'message': 'Hysteria installed',
'log': log,
'port': str(port),
'domain': domain,
'email': email,
}
def remove_container(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
name = self._container_name(protocol_type)
base = self._base_dir(protocol_type)
self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true")
self.ssh.run_sudo_command(f"rm -rf {_q(base)}")
return True
def get_server_config(self, protocol_type=None):
return self._read_file(self.config_path)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.config_path, config_text or '')
self._reload_container()
return True
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
cname = c.get('name') or c.get('id')
result.append({
'clientId': c.get('id'),
'client_id': c.get('id'),
'id': c.get('id'),
'name': cname,
'email': cname,
'enabled': c.get('enabled', True),
'userData': {'clientName': cname, 'enabled': c.get('enabled', True)},
})
return result
def add_client(self, protocol_type, name, host, port=None):
meta = self._ensure_secrets(self._read_metadata())
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
client_id = secrets.token_hex(8)
clients = self._read_clients()
clients.append({
'id': client_id,
'name': name or f'client-{client_id[:6]}',
'enabled': True,
})
self._write_clients(clients)
# Shared password auth — refresh yaml only if secrets/port changed
self._write_config_from_clients(port)
self._reload_container()
config = self._build_share_uri(host, port, domain, name or client_id, meta)
return {'client_id': client_id, 'config': config}
def get_client_config(self, protocol_type, client_id, host, port=None):
meta = self._ensure_secrets(self._read_metadata())
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
client = next((c for c in clients if c.get('id') == client_id), None)
if not client:
raise RuntimeError('Client not found')
if client.get('enabled', True) is False:
raise RuntimeError('Client is disabled')
return self._build_share_uri(
host, port, domain,
client.get('name') or client_id,
meta,
)
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
self._write_clients(clients)
return True
def toggle_client(self, protocol_type, client_id, enable=True):
clients = self._read_clients()
found = False
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enable)
found = True
break
if not found:
raise RuntimeError('Client not found')
self._write_clients(clients)
return True
+604
View File
@@ -0,0 +1,604 @@
"""
NaiveProxy manager Caddy + klzgrad/forwardproxy (naive fork).
Server side is the naïve Caddy forward_proxy from:
https://github.com/klzgrad/naiveproxy
https://github.com/klzgrad/forwardproxy
Uses the official prebuilt Caddy binary on amd64, or builds via xcaddy on other
arches. Clients authenticate with HTTP basic auth; share links are
naive+https://user:pass@domain URIs (NekoRay / sing-box compatible).
Requires free TCP 80 (ACME) and 443 (HTTPS / HTTP2 proxy).
"""
from __future__ import annotations
import json
import logging
import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
def _q(value):
return shlex.quote(str(value))
def _rand_token(length=16):
alphabet = string.ascii_lowercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
class NaiveProxyManager:
PROTOCOL = 'naiveproxy'
CONTAINER_NAME = 'amnezia-naiveproxy'
IMAGE_NAME = 'amnezia-naiveproxy:local'
BASE_DIR = '/opt/amnezia/naiveproxy'
DEFAULT_PORT = 443
# Official Caddy+forwardproxy naive build (linux amd64)
CADDY_RELEASE = 'v2.11.2-naive'
CADDY_URL = (
'https://github.com/klzgrad/forwardproxy/releases/download/'
f'{CADDY_RELEASE}/caddy-forwardproxy-naive.tar.xz'
)
def __init__(self, ssh, protocol='naiveproxy'):
self.ssh = ssh
self.protocol = protocol or self.PROTOCOL
self.container_name = self.CONTAINER_NAME
self.base_dir = self.BASE_DIR
self.caddyfile_path = f'{self.base_dir}/Caddyfile'
self.clients_path = f'{self.base_dir}/clients.json'
self.meta_path = f'{self.base_dir}/metadata.json'
self.html_dir = f'{self.base_dir}/html'
self.bin_dir = f'{self.base_dir}/bin'
self.data_dir = f'{self.base_dir}/caddy-data'
self.config_dir = f'{self.base_dir}/caddy-config'
# ===================== STATUS =====================
def check_docker_installed(self):
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
if code != 0:
return False
out2, _, _ = self.ssh.run_command(
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
)
return 'active' in out2 or 'running' in out2.lower()
def check_protocol_installed(self, protocol_type=None):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
)
return self.CONTAINER_NAME in out.strip().split('\n')
def check_container_running(self, protocol_type=None):
out, _, _ = self.ssh.run_sudo_command(
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
)
return 'Up' in out
def get_logs(self, protocol_type=None, tail=200):
tail = max(20, min(int(tail or 200), 2000))
out, err, code = self.ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {self.CONTAINER_NAME} 2>&1",
timeout=30,
)
text = (out or err or '').strip()
if code != 0 and not text:
text = f'(no logs: exit {code})'
return text
def get_container_diagnostics(self, protocol_type=None):
fmt = (
'{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|'
'{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|'
'{{.State.StartedAt}}'
)
out, _, code = self.ssh.run_sudo_command(
f"docker inspect -f '{fmt}' {self.CONTAINER_NAME} 2>/dev/null"
)
diag = {
'status': 'unknown',
'running': False,
'exit_code': None,
'error_summary': '',
'recent_logs': '',
}
if code == 0 and out.strip():
parts = out.strip().split('|')
while len(parts) < 7:
parts.append('')
diag['status'] = parts[0]
diag['running'] = parts[1].lower() == 'true'
try:
diag['exit_code'] = int(parts[2])
except ValueError:
diag['exit_code'] = parts[2]
if parts[3].lower() == 'true':
diag['error_summary'] = 'OOM killed'
elif parts[4]:
diag['error_summary'] = parts[4]
elif not diag['running'] and diag['exit_code'] not in (0, '0', None, ''):
diag['error_summary'] = f'Exited with code {diag["exit_code"]}'
diag['recent_logs'] = self.get_logs(protocol_type, tail=80)
if not diag['error_summary'] and not diag['running']:
logs = diag['recent_logs'].lower()
if 'address already in use' in logs or 'bind:' in logs:
diag['error_summary'] = (
'TCP 80/443 already in use — free them or stop conflicting services'
)
return diag
def get_server_status(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type) if exists else False
meta = self._read_metadata() if exists else {}
clients = self._read_clients() if exists else []
port = int(meta.get('port') or self.DEFAULT_PORT)
return {
'container_exists': exists,
'container_running': running,
'port': port,
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'clients_count': len(clients),
'protocol': protocol_type,
'base_protocol': self.PROTOCOL,
'instance': 1,
'container_name': self.CONTAINER_NAME,
}
# ===================== IO HELPERS =====================
def _validate_domain(self, domain):
domain = (domain or '').strip().lower()
if not domain or len(domain) > 253:
raise ValueError('Domain is required')
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError('Valid Let\'s Encrypt email is required')
return email
def _read_file(self, path):
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
return out if code == 0 else ''
def _write_file(self, path, content):
# Write via base64 to avoid shell escaping issues
import base64
b64 = base64.b64encode((content or '').encode('utf-8')).decode('ascii')
script = (
f"mkdir -p $(dirname {_q(path)}) && "
f"echo {_q(b64)} | base64 -d > {_q(path)}"
)
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
if code != 0:
raise RuntimeError(f'Failed to write {path}: {err or out}')
def _read_metadata(self):
raw = self._read_file(self.meta_path).strip()
if not raw:
return {}
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _write_metadata(self, meta):
self._write_file(self.meta_path, json.dumps(meta, indent=2, ensure_ascii=False))
def _read_clients(self):
raw = self._read_file(self.clients_path).strip()
if not raw:
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except Exception:
return []
def _write_clients(self, clients):
self._write_file(self.clients_path, json.dumps(clients, indent=2, ensure_ascii=False))
def _caddy_auth_lines(self, clients):
lines = []
for c in clients:
if c.get('enabled', True) is False:
continue
user = (c.get('username') or '').strip()
password = (c.get('password') or '').strip()
if not user or not password:
continue
# Credentials are alphanumeric from our generator — safe in Caddyfile
lines.append(f' basic_auth {user} {password}')
return lines
def _build_caddyfile(self, domain, email, clients, probe_path):
auth_lines = self._caddy_auth_lines(clients)
if not auth_lines:
# Keep a non-shared bootstrap user so the proxy is never open
auth_lines = [f' basic_auth bootstrap {_rand_token(24)}']
auth_block = '\n'.join(auth_lines)
# probe_resistance looks like a fake domain to browsers
probe = (probe_path or _rand_token(12)).strip('/')
if '.' not in probe:
probe = f'{probe}.com'
return f"""{{
order forward_proxy before file_server
admin off
log {{
exclude http.log.error
}}
}}
:443, {domain} {{
tls {email}
encode
forward_proxy {{
{auth_block}
hide_ip
hide_via
probe_resistance {probe}
}}
file_server {{
root /var/www/html
}}
}}
"""
def _write_config_from_clients(self):
meta = self._read_metadata()
domain = meta.get('domain') or ''
email = meta.get('email') or ''
if not domain or not email:
raise RuntimeError('Domain and email are required in metadata')
clients = self._read_clients()
probe = meta.get('probe_resistance') or _rand_token(12)
if not meta.get('probe_resistance'):
meta['probe_resistance'] = probe
self._write_metadata(meta)
text = self._build_caddyfile(domain, email, clients, probe)
self._write_file(self.caddyfile_path, text)
def _ensure_placeholder_site(self):
index = f'{self.html_dir}/index.html'
existing = self._read_file(index)
if existing.strip():
return
html = (
'<!DOCTYPE html><html><head><meta charset="utf-8">'
'<title>Welcome</title></head><body><h1>It works</h1></body></html>\n'
)
self._write_file(index, html)
def _host_arch(self):
out, _, _ = self.ssh.run_command("uname -m")
return (out or '').strip().lower()
def _prepare_caddy_binary(self, log):
"""Download official amd64 build or compile with xcaddy for other arches."""
self.ssh.run_sudo_command(f"mkdir -p {_q(self.bin_dir)}")
arch = self._host_arch()
caddy_path = f'{self.bin_dir}/caddy'
if arch in ('x86_64', 'amd64'):
script = f"""
set -e
cd {_q(self.bin_dir)}
curl -fsSL -o caddy-forwardproxy-naive.tar.xz {_q(self.CADDY_URL)}
tar -xf caddy-forwardproxy-naive.tar.xz
if [ -f caddy-forwardproxy-naive/caddy ]; then
cp -f caddy-forwardproxy-naive/caddy ./caddy
elif [ -f caddy ]; then
true
else
echo "caddy binary not found in archive" >&2
exit 1
fi
chmod +x ./caddy
./caddy version
"""
out, err, code = self.ssh.run_sudo_command(f"bash -lc {_q(script)}", timeout=300)
if code != 0:
raise RuntimeError(f'Failed to download NaiveProxy Caddy: {err or out}')
log.append(f'Downloaded official Caddy+forwardproxy ({self.CADDY_RELEASE})')
return caddy_path
# arm64 / other: build with xcaddy inside golang image
build_script = f"""
set -e
docker pull golang:1.22-bookworm
docker run --rm -v {_q(self.bin_dir)}:/out -w /tmp golang:1.22-bookworm bash -lc '
set -e
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
/go/bin/xcaddy build --with github.com/caddyserver/forwardproxy=github.com/klzgrad/forwardproxy@naive
cp -f caddy /out/caddy
chmod +x /out/caddy
'
{_q(caddy_path)} version
"""
out, err, code = self.ssh.run_sudo_command(f"bash -lc {_q(build_script)}", timeout=900)
if code != 0:
raise RuntimeError(f'Failed to build NaiveProxy Caddy for {arch}: {err or out}')
log.append(f'Built Caddy+forwardproxy via xcaddy for {arch}')
return caddy_path
def _build_docker_image(self, log):
dockerfile = f"""FROM debian:bookworm-slim
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ca-certificates \\
&& rm -rf /var/lib/apt/lists/*
COPY caddy /usr/bin/caddy
RUN chmod +x /usr/bin/caddy
WORKDIR /etc/caddy
EXPOSE 80 443
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
"""
self._write_file(f'{self.bin_dir}/Dockerfile', dockerfile)
out, err, code = self.ssh.run_sudo_command(
f"docker build -t {self.IMAGE_NAME} {_q(self.bin_dir)}",
timeout=300,
)
if code != 0:
raise RuntimeError(f'Failed to build Docker image: {err or out}')
log.append(f'Built image {self.IMAGE_NAME}')
def _start_container(self):
self.ssh.run_sudo_command(
f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true"
)
self.ssh.run_sudo_command(f"mkdir -p {_q(self.data_dir)} {_q(self.config_dir)} {_q(self.html_dir)}")
run_cmd = (
f"docker run -d --name {self.CONTAINER_NAME} --restart unless-stopped "
f"--network host "
f"-v {_q(self.base_dir)}:/etc/caddy "
f"-v {_q(self.html_dir)}:/var/www/html "
f"-v {_q(self.data_dir)}:/data "
f"-v {_q(self.config_dir)}:/config "
f"-e XDG_DATA_HOME=/data "
f"-e XDG_CONFIG_HOME=/config "
f"{self.IMAGE_NAME}"
)
_, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60)
if code != 0:
raise RuntimeError(f'Failed to start NaiveProxy: {err}')
time.sleep(2.0)
if not self.check_container_running():
diag = self.get_container_diagnostics()
raise RuntimeError(
diag.get('error_summary')
or 'NaiveProxy container exited — free TCP 80/443 and check logs'
)
return True
def _reload_container(self):
try:
self._write_config_from_clients()
# Prefer graceful reload; fall back to recreate
out, _, code = self.ssh.run_sudo_command(
f"docker exec {self.CONTAINER_NAME} caddy reload "
f"--config /etc/caddy/Caddyfile --adapter caddyfile 2>&1",
timeout=30,
)
if code != 0:
logger.warning('Caddy reload failed (%s), recreating container', out)
self._start_container()
except Exception as e:
logger.warning('NaiveProxy reload failed: %s', e)
self._start_container()
def _build_share_uri(self, domain, username, password, name, port=443):
"""v2rayN / NekoRay / sing-box compatible share link.
Always include an explicit port without `:443` v2rayN often treats
the port as empty/80 and shows «Свойство Порт недопустимо» / SSL errors.
"""
# Keep credentials unescaped when alphanumeric (our generator); quote otherwise.
user = quote(username or '', safe='')
pw = quote(password or '', safe='')
host = (domain or '').strip().lower()
port = int(port or 443)
if port < 1 or port > 65535:
port = 443
# Remark: ASCII-safe, no spaces that break some importers
remark = re.sub(r'[^\w.\-]+', '-', (name or 'naiveproxy').strip())[:48] or 'naiveproxy'
return f'naive+https://{user}:{pw}@{host}:{port}#{remark}'
# ===================== INSTALL / REMOVE =====================
def install_protocol(self, protocol_type=None, port=None, domain=None, email=None):
protocol_type = protocol_type or self.protocol
if not self.check_docker_installed():
return {'status': 'error', 'message': 'Docker not installed'}
try:
domain = self._validate_domain(domain)
email = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
port = int(port or self.DEFAULT_PORT)
if port not in (443,):
# Caddy ACME + naive typically bind :443; keep simple for beta
port = 443
log = []
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)} {_q(self.html_dir)} {_q(self.bin_dir)}")
if self.check_protocol_installed(protocol_type):
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
log.append('Removed previous container')
try:
self._prepare_caddy_binary(log)
self._build_docker_image(log)
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
self._write_clients([])
meta = {
'domain': domain,
'email': email,
'port': port,
'probe_resistance': _rand_token(12),
'caddy_release': self.CADDY_RELEASE,
}
self._write_metadata(meta)
self._ensure_placeholder_site()
try:
self._write_config_from_clients()
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append('Wrote Caddyfile (forward_proxy + ACME TLS)')
try:
self._start_container()
except Exception as e:
return {'status': 'error', 'message': str(e), 'log': log}
log.append(
f'Started {self.CONTAINER_NAME} on TCP 443 (host network). '
'Ports 80 and 443 must stay free for ACME and HTTPS.'
)
return {
'status': 'success',
'message': 'NaiveProxy installed',
'log': log,
'port': str(port),
'domain': domain,
'email': email,
}
def remove_container(self, protocol_type=None):
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
self.ssh.run_sudo_command(f"rm -rf {_q(self.base_dir)}")
return True
def get_server_config(self, protocol_type=None):
return self._read_file(self.caddyfile_path)
def save_server_config(self, protocol_type=None, config_text=''):
self._write_file(self.caddyfile_path, config_text or '')
self._reload_container()
return True
def get_settings(self):
meta = self._read_metadata()
return {
'port': int(meta.get('port') or self.DEFAULT_PORT),
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'protocol': self.protocol,
}
def update_settings(self, domain=None, email=None):
meta = self._read_metadata()
try:
if domain is not None and str(domain).strip():
meta['domain'] = self._validate_domain(domain)
if email is not None and str(email).strip():
meta['email'] = self._validate_email(email)
except ValueError as e:
return {'status': 'error', 'message': str(e)}
if not meta.get('domain') or not meta.get('email'):
return {'status': 'error', 'message': 'Domain and email are required'}
meta['port'] = int(meta.get('port') or self.DEFAULT_PORT)
self._write_metadata(meta)
try:
self._write_config_from_clients()
self._start_container()
except Exception as e:
return {'status': 'error', 'message': str(e), **self.get_settings()}
return {'status': 'success', 'message': 'NaiveProxy settings updated', **self.get_settings()}
# ===================== CLIENTS =====================
def get_clients(self, protocol_type=None):
clients = self._read_clients()
result = []
for c in clients:
cname = c.get('name') or c.get('username') or c.get('id')
result.append({
'clientId': c.get('id'),
'client_id': c.get('id'),
'id': c.get('id'),
'name': cname,
'email': cname,
'enabled': c.get('enabled', True),
'userData': {
'clientName': cname,
'enabled': c.get('enabled', True),
'username': c.get('username'),
},
})
return result
def add_client(self, protocol_type, name, host, port=None):
meta = self._read_metadata()
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
client_id = secrets.token_hex(8)
username = f'u{client_id[:8]}'
password = _rand_token(20)
clients = self._read_clients()
clients.append({
'id': client_id,
'name': name or f'client-{client_id[:6]}',
'username': username,
'password': password,
'enabled': True,
})
self._write_clients(clients)
self._reload_container()
config = self._build_share_uri(domain, username, password, name or client_id, port)
return {'client_id': client_id, 'config': config}
def get_client_config(self, protocol_type, client_id, host, port=None):
meta = self._read_metadata()
domain = meta.get('domain') or host
port = int(port or meta.get('port') or self.DEFAULT_PORT)
clients = self._read_clients()
client = next((c for c in clients if c.get('id') == client_id), None)
if not client:
raise RuntimeError('Client not found')
if client.get('enabled', True) is False:
raise RuntimeError('Client is disabled')
return self._build_share_uri(
domain,
client.get('username'),
client.get('password'),
client.get('name') or client_id,
port,
)
def remove_client(self, protocol_type, client_id):
clients = [c for c in self._read_clients() if c.get('id') != client_id]
self._write_clients(clients)
self._reload_container()
return True
def toggle_client(self, protocol_type, client_id, enable=True):
clients = self._read_clients()
found = False
for c in clients:
if c.get('id') == client_id:
c['enabled'] = bool(enable)
found = True
break
if not found:
raise RuntimeError('Client not found')
self._write_clients(clients)
self._reload_container()
return True
+397
View File
@@ -0,0 +1,397 @@
"""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__)
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]:
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():
time.sleep(max(0.5, float(delay_sec)))
try:
argv = [sys.executable] + sys.argv
logger.info('Restarting panel after update: %s', argv)
os.execv(sys.executable, argv)
except Exception:
logger.exception('Failed to restart after update; exiting')
os._exit(0)
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
+358
View File
@@ -944,6 +944,11 @@ a:hover {
color: #38bdf8;
}
.protocol-hysteria .protocol-icon {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(99, 102, 241, 0.1));
color: #38bdf8;
}
.protocol-dns .protocol-icon {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
color: var(--success);
@@ -1277,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);
@@ -1289,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;
@@ -1618,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);
}
+11 -3
View File
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
_callback_refs = {}
_pending_inputs = {}
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "wireguard"}
CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "naiveproxy", "wireguard"}
SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
@@ -137,6 +137,8 @@ def _protocol_display_name(protocol: str) -> str:
"awg_legacy": "AmneziaWG Legacy",
"xray": "Xray",
"telemt": "Telemt",
"hysteria": "Hysteria 2",
"naiveproxy": "NaiveProxy",
"dns": "AmneziaDNS",
"wireguard": "WireGuard",
"socks5": "SOCKS5",
@@ -338,6 +340,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager
from managers.telemt_manager import TelemtManager
from managers.hysteria_manager import HysteriaManager
from managers.naiveproxy_manager import NaiveProxyManager
from managers.wireguard_manager import WireGuardManager
from managers.dns_manager import DNSManager
from managers.socks5_manager import Socks5Manager
@@ -356,6 +360,10 @@ def _get_ssh_and_manager(server: dict, proto: str):
manager = XrayManager(ssh, proto)
elif base == "telemt":
manager = TelemtManager(ssh, proto)
elif base == "hysteria":
manager = HysteriaManager(ssh, proto)
elif base == "naiveproxy":
manager = NaiveProxyManager(ssh, proto)
elif base == "wireguard":
manager = WireGuardManager(ssh)
elif base == "dns":
@@ -561,7 +569,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p
server_name = server.get("name") or server.get("host", "Unknown")
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server_name)}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
is_link_proto = _proto_base(proto) in ("xray", "telemt")
is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy")
if is_link_proto:
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
else:
@@ -918,7 +926,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int,
async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable):
await api.send_message(chat_id, f"✅ <b>{_e(conn_name)}</b>\n🌐 Server: <b>{_e(server.get('name') or server.get('host'))}</b>\n🔌 Protocol: <b>{_e(proto.upper())}</b>")
if _proto_base(proto) in ("xray", "telemt"):
if _proto_base(proto) in ("xray", "telemt", "hysteria", "naiveproxy"):
await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
else:
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>")
+3
View File
@@ -105,6 +105,9 @@
if (p === 'awg_legacy') return 'AWG Legacy';
if (p === 'xray') return 'Xray';
if (p === 'xui') return '3x-ui VLESS';
if (p === 'hysteria') return 'Hysteria 2';
if (p === 'naiveproxy') return 'NaiveProxy';
if (p === 'telemt') return 'Telemt';
return (p || '').toUpperCase();
}
+28
View File
@@ -19,6 +19,8 @@
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
draggable="true">
<div class="server-meta">
<div class="server-icon">{{ icon('server') }}</div>
@@ -137,6 +139,16 @@
</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_ssl_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('server_ssl_email') }}</label>
<input class="form-input" type="email" id="editServerSslEmail" placeholder="admin@example.com">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button>
@@ -202,6 +214,16 @@
</div>
</div>
<div class="form-group" style="margin-top: var(--space-md)">
<label class="form-label">{{ _('server_ssl_domain') }}</label>
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('server_ssl_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('server_ssl_email') }}</label>
<input class="form-input" type="email" id="serverSslEmail" placeholder="admin@example.com">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button>
@@ -244,6 +266,8 @@
username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').value,
ssl_domain: document.getElementById('serverSslDomain').value.trim(),
ssl_email: document.getElementById('serverSslEmail').value.trim(),
};
const result = await apiCall('/api/servers/add', 'POST', data);
@@ -292,6 +316,8 @@
document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').value = '';
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
// Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key';
@@ -321,6 +347,8 @@
// Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').value || null,
ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
ssl_email: document.getElementById('editServerSslEmail').value.trim(),
};
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success');
+3 -1
View File
@@ -196,7 +196,7 @@
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt'];
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
@@ -204,6 +204,8 @@
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
+3 -2
View File
@@ -118,10 +118,11 @@
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
// Telemt uses share links, not vpn:// Amnezia format
// Telemt / Hysteria / NaiveProxy use share links, not vpn:// Amnezia format
const vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn');
if (proto === 'telemt') {
const base = String(proto || '').split('__')[0];
if (proto === 'telemt' || base === 'hysteria' || base === 'naiveproxy') {
vpnTab.style.display = 'none';
} else {
vpnTab.style.display = '';
+504 -10
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "base.html" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ server.name }}{% endblock %}
@@ -295,6 +295,55 @@
</div>
</div>
<!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('refresh') }}</div>
<div class="flex gap-sm" id="hysteria-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Hysteria 2</div>
<div class="protocol-desc">
{{ _('hysteria_desc') }}
</div>
<div class="protocol-status" id="hysteria-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="hysteria-info" class="hidden">
<div class="protocol-info" id="hysteria-info-grid"></div>
</div>
<div class="flex gap-sm" id="hysteria-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('hysteria')" id="hysteria-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- NaiveProxy Card -->
<div class="card card-hover protocol-card protocol-naiveproxy" id="proto-naiveproxy">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('link') }}</div>
<div class="flex gap-sm" id="naiveproxy-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">NaiveProxy <span
style="font-size:0.65rem; background:var(--success, #16a34a); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">STABLE</span></div>
<div class="protocol-desc">
{{ _('naiveproxy_desc') }}
</div>
<div class="protocol-status" id="naiveproxy-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="naiveproxy-info" class="hidden">
<div class="protocol-info" id="naiveproxy-info-grid"></div>
</div>
<div class="flex gap-sm" id="naiveproxy-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('naiveproxy')" id="naiveproxy-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- WireGuard Card -->
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -495,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>
@@ -657,6 +717,47 @@
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
</div>
<div id="hysteriaOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP) *</label>
<input class="form-input" type="number" id="installHysteriaPort" value="8998" min="1" max="65535">
<div class="form-hint">{{ _('hysteria_port_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()">
<div class="form-hint" id="hysteriaDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="installHysteriaEmail" placeholder="admin@example.com">
</div>
<div class="form-hint">{{ _('hysteria_install_hint') }}</div>
</div>
<div id="naiveproxyOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('naiveproxy_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_domain') }}</label>
<input class="form-input" type="text" id="installNaiveproxyDomain" placeholder="vpn.example.com" oninput="updateNaiveproxyDnsHint()">
<div class="form-hint" id="naiveproxyDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_email') }}</label>
<input class="form-input" type="email" id="installNaiveproxyEmail" placeholder="admin@example.com">
</div>
<div class="form-hint">{{ _('naiveproxy_install_hint') }}</div>
<div class="form-hint" style="margin-top: var(--space-sm);">{{ _('naiveproxy_port_hint') }}</div>
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_hint') }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
@@ -711,6 +812,45 @@
</div>
</div>
<!-- ===== Hysteria Settings Modal ===== -->
<div class="modal-backdrop" id="hysteriaSettingsModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="hysteriaSettingsTitle">{{ _('hysteria_settings_title') }}</h2>
<button class="modal-close" onclick="closeModal('hysteriaSettingsModal')">×</button>
</div>
<input type="hidden" id="hysteriaSetProto" value="hysteria" />
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="hysteriaSetDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('hysteria_ssl_change_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="hysteriaSetEmail" placeholder="admin@example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP)</label>
<input class="form-input" type="number" id="hysteriaSetPort" min="1" max="65535" value="8998">
<div class="form-hint">{{ _('hysteria_port_change_hint') }}</div>
</div>
<div class="modal-footer" style="flex-wrap: wrap; gap: var(--space-sm);">
<button class="btn btn-secondary" onclick="closeModal('hysteriaSettingsModal')">{{ _('cancel') }}</button>
<button class="btn btn-secondary" onclick="renewHysteriaSsl()" id="hysteriaRenewBtn">
<span id="hysteriaRenewBtnText">{{ _('hysteria_renew_ssl') }}</span>
<div class="spinner hidden" id="hysteriaRenewSpinner"></div>
</button>
<button class="btn btn-primary" onclick="saveHysteriaSettings()" id="hysteriaSaveBtn">
<span id="hysteriaSaveBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="hysteriaSaveSpinner"></div>
</button>
</div>
</div>
</div>
<!-- ===== NGINX Site Modal ===== -->
<div class="modal-backdrop" id="siteConfigModal">
<div class="modal" style="max-width:680px;">
@@ -840,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;">
@@ -859,6 +1028,7 @@
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<div class="config-text" id="configText"></div>
<div class="form-hint hidden" id="configClientHint" style="margin-top: var(--space-sm);"></div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy') }}
@@ -960,13 +1130,18 @@
<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 }};
const MARKETPLACE_APPS = [
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
{ proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
@@ -1002,7 +1177,7 @@
}
function isMultiInstanceBase(proto) {
return ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'].includes(protoBase(proto));
return ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'].includes(protoBase(proto));
}
function nextSuggestedPort(base, fallback) {
@@ -1207,6 +1382,8 @@
case 'awg_legacy': title = 'AmneziaWG Legacy'; break;
case 'xray': title = 'Xray'; break;
case 'telemt': title = 'Telemt'; break;
case 'hysteria': title = 'Hysteria 2'; break;
case 'naiveproxy': title = 'NaiveProxy'; break;
case 'wireguard': title = 'WireGuard'; break;
case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break;
@@ -1266,7 +1443,7 @@
for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
updateProtocolCard(proto, info);
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'].includes(protoBase(proto));
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'wireguard'].includes(protoBase(proto));
if (info.container_running && isVPN) {
const opt = document.createElement('option');
opt.value = proto;
@@ -1494,7 +1671,13 @@
if (isService) {
grid = buildServiceInfoGrid(proto, info);
} else if (info.port) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
if (protoBase(proto) === 'hysteria' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
if (protoBase(proto) === 'naiveproxy' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
}
if (!isService && info.clients_count !== undefined) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
@@ -1529,6 +1712,21 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (protoBase(proto) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
<button class="btn btn-secondary btn-sm" onclick="openHysteriaSettings('${proto}')">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
showConnectionsSection();
} else if (protoBase(proto) === 'naiveproxy') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
showConnectionsSection();
} else {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
@@ -1565,6 +1763,7 @@
} else if (info.container_exists) {
installedProtocols[proto] = true;
const errText = String(info.error || '').trim();
const portBusy = !!info.port_busy || /address already in use|UDP port \d+ already in use|already in use/i.test(errText);
statusEl.innerHTML = errText
? `<span class="badge badge-danger" style="max-width:100%; white-space:normal; text-align:left; line-height:1.35;"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 120))}</span>`
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
@@ -1583,6 +1782,25 @@
if (errText) {
grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`;
}
if (protoBase(proto) === 'hysteria') {
const curPort = parseInt(info.port, 10) || 443;
const suggest = (curPort === 443 || portBusy) ? 8998 : curPort;
grid += `
<div class="protocol-info-item" style="grid-column:1/-1; display:block; padding-top:8px;">
<span class="protocol-info-label" style="display:block; margin-bottom:6px;">${_('hysteria_change_port')} (UDP)</span>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="form-input" type="number" id="hysteriaInlinePort-${protoDomKey(proto)}"
value="${suggest}" min="1" max="65535" style="width:120px; flex:0 0 auto;">
<button class="btn btn-primary btn-sm" type="button"
onclick="applyHysteriaPort('${proto}', document.getElementById('hysteriaInlinePort-${protoDomKey(proto)}').value)">
${_('save')} &amp; ${_('start_btn')}
</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openHysteriaSettings('${proto}')">${_('hysteria_settings_title')}</button>
</div>
<div class="form-hint" style="margin-top:6px;">${_('hysteria_port_change_hint')}</div>
</div>
`;
}
infoGrid.innerHTML = grid;
}
if (isService) {
@@ -1592,6 +1810,13 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (protoBase(proto) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openHysteriaSettings('${proto}')" style="flex:1">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
<button class="btn btn-secondary btn-sm" onclick="openInstallModal('${proto}')">${_('reinstall')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
@@ -1872,6 +2097,22 @@
hint.innerHTML = `${_('nginx_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function updateHysteriaDnsHint() {
const input = document.getElementById('installHysteriaDomain');
const hint = document.getElementById('hysteriaDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('hysteria_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function updateNaiveproxyDnsHint() {
const input = document.getElementById('installNaiveproxyDomain');
const hint = document.getElementById('naiveproxyDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function openInstallModal(proto, installAnother = false) {
const base = protoBase(proto);
currentInstallProto = installAnother ? base : proto;
@@ -1887,11 +2128,15 @@
const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions');
const nginxOpts = document.getElementById('nginxOptions');
const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none';
adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none';
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
if (portGroup) portGroup.style.display = '';
if (base === 'dns') {
@@ -1928,7 +2173,37 @@
portInput.disabled = false;
portHint.textContent = _('nginx_port_hint');
nginxOpts.style.display = 'block';
const nxDomain = document.getElementById('installNginxDomain');
const nxEmail = document.getElementById('installNginxEmail');
if (nxDomain && !nxDomain.value && SERVER_SSL_DOMAIN) nxDomain.value = SERVER_SSL_DOMAIN;
if (nxEmail && !nxEmail.value && SERVER_SSL_EMAIL) nxEmail.value = SERVER_SSL_EMAIL;
updateNginxDnsHint();
} else if (base === 'hysteria') {
if (portGroup) portGroup.style.display = 'none';
const suggested = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.value = suggested;
portInput.disabled = false;
hysteriaOpts.style.display = 'block';
const hyPort = document.getElementById('installHysteriaPort');
if (hyPort) {
hyPort.value = suggested;
hyPort.oninput = () => { portInput.value = hyPort.value; };
}
const hyDomain = document.getElementById('installHysteriaDomain');
const hyEmail = document.getElementById('installHysteriaEmail');
if (hyDomain && !hyDomain.value && SERVER_SSL_DOMAIN) hyDomain.value = SERVER_SSL_DOMAIN;
if (hyEmail && !hyEmail.value && SERVER_SSL_EMAIL) hyEmail.value = SERVER_SSL_EMAIL;
updateHysteriaDnsHint();
} else if (base === 'naiveproxy') {
if (portGroup) portGroup.style.display = 'none';
portInput.value = '443';
portInput.disabled = false;
if (naiveproxyOpts) naiveproxyOpts.style.display = 'block';
const npDomain = document.getElementById('installNaiveproxyDomain');
const npEmail = document.getElementById('installNaiveproxyEmail');
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
updateNaiveproxyDnsHint();
} else {
portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
@@ -1995,6 +2270,17 @@
} else if (protoBase(currentInstallProto) === 'nginx') {
params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
params.nginx_email = document.getElementById('installNginxEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'hysteria') {
const hyPortEl = document.getElementById('installHysteriaPort');
if (hyPortEl && hyPortEl.value) {
params.port = hyPortEl.value;
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
params.port = '443';
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').value.trim();
}
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
clearInterval(progressInterval);
@@ -2210,6 +2496,107 @@
}
}
// ========== Hysteria Settings (port) ==========
async function applyHysteriaPort(proto, portValue) {
const port = parseInt(portValue, 10);
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
try {
showToast(_('saving') || 'Saving...', 'info');
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto || 'hysteria', port,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
openHysteriaSettings(proto);
}
}
async function openHysteriaSettings(proto = 'hysteria') {
const live = currentProtocolStatus[proto] || {};
const livePort = live.port || 443;
const suggest = (parseInt(livePort, 10) === 443) ? 8998 : (livePort || 8998);
document.getElementById('hysteriaSetProto').value = proto;
document.getElementById('hysteriaSettingsTitle').textContent =
`${_('hysteria_settings_title')} — ${getProtoTitle(proto)}`;
document.getElementById('hysteriaSetDomain').value = live.domain || SERVER_SSL_DOMAIN || '';
document.getElementById('hysteriaSetEmail').value = live.email || SERVER_SSL_EMAIL || '';
document.getElementById('hysteriaSetPort').value = suggest;
openModal('hysteriaSettingsModal');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
if (data.domain) document.getElementById('hysteriaSetDomain').value = data.domain;
if (data.email) document.getElementById('hysteriaSetEmail').value = data.email;
const p = parseInt(data.port, 10);
if (p && p !== 443) document.getElementById('hysteriaSetPort').value = p;
else if (p === 443) document.getElementById('hysteriaSetPort').value = 8998;
} catch (_) { /* modal already open with local values */ }
}
async function saveHysteriaSettings(opts = {}) {
const btn = document.getElementById('hysteriaSaveBtn');
const text = document.getElementById('hysteriaSaveBtnText');
const spinner = document.getElementById('hysteriaSaveSpinner');
const renewBtn = document.getElementById('hysteriaRenewBtn');
const renewText = document.getElementById('hysteriaRenewBtnText');
const renewSpinner = document.getElementById('hysteriaRenewSpinner');
const proto = document.getElementById('hysteriaSetProto').value || 'hysteria';
const port = parseInt(document.getElementById('hysteriaSetPort').value, 10);
const domain = document.getElementById('hysteriaSetDomain').value.trim();
const email = document.getElementById('hysteriaSetEmail').value.trim();
const renewSsl = !!opts.renew_ssl;
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
if ((renewSsl || domain) && (!domain || !email)) {
showToast(_('error') + ': ' + _('hysteria_ssl_required'), 'error');
return;
}
btn.disabled = true;
if (renewBtn) renewBtn.disabled = true;
if (renewSsl) {
renewText.textContent = _('saving') || 'Saving...';
renewSpinner.classList.remove('hidden');
} else {
text.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
}
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto, port, domain, email, renew_ssl: renewSsl,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
closeModal('hysteriaSettingsModal');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
if (renewBtn) renewBtn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
if (renewText) renewText.textContent = _('hysteria_renew_ssl');
if (renewSpinner) renewSpinner.classList.add('hidden');
}
}
async function renewHysteriaSsl() {
return saveHysteriaSettings({ renew_ssl: true });
}
// ========== SOCKS5 Settings ==========
async function openSocks5Settings(proto = 'socks5') {
currentSocks5Proto = proto || 'socks5';
@@ -2262,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');
@@ -2276,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 = {};
@@ -2294,7 +2773,7 @@
const sent = userData.dataSent || '';
const initial = name.charAt(0).toUpperCase();
const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false);
const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt';
const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy';
const assignedUser = client.assigned_user || '';
let metaHtml = '';
@@ -2324,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>
@@ -2332,7 +2814,7 @@
</div>
</div>
<div class="client-actions">
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'})" title="${_('config')}">${uiIcon('file')}</button>
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy'})" title="${_('config')}">${uiIcon('file')}</button>
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">${uiIcon('pencil')}</button>` : ''}
<button class="btn btn-secondary btn-sm btn-icon" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">${uiIcon('trash')}</button>
@@ -2340,6 +2822,7 @@
</div>
`;
});
updateMoveSelection();
} catch (err) {
loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error');
@@ -2384,8 +2867,8 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
@@ -2463,9 +2946,9 @@
const proto = document.getElementById('connProtoSelect').value;
// Restore tabs visibility first
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.getElementById('panel-qr').style.display = '';
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.querySelectorAll('.config-tab')[2].style.display = '';
// Client was created via native Amnezia app — private key is not stored server-side
@@ -2497,6 +2980,16 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
const hintEl = document.getElementById('configClientHint');
if (hintEl) {
if (protoBase(proto) === 'naiveproxy') {
hintEl.textContent = _('naiveproxy_client_hint');
hintEl.classList.remove('hidden');
} else {
hintEl.textContent = '';
hintEl.classList.add('hidden');
}
}
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
@@ -2609,6 +3102,7 @@
}
// ========== Init ==========
applyInstalledAppsVisibility();
checkServer();
+365 -98
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 -->
@@ -597,15 +656,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 +984,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 +1033,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
@@ -963,12 +1046,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 +1063,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 +1108,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`);
@@ -1321,7 +1489,7 @@
}
const guestServersData = {{ servers | default([]) | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt'];
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
@@ -1329,6 +1497,8 @@
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
@@ -1480,42 +1650,139 @@
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 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');
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: true,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
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…') }}";
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);
/* ========== API Tokens ========== */
function fmtTokenDate(iso) {
+3 -1
View File
@@ -631,7 +631,7 @@
const select = document.getElementById(selectId);
const group = groupId ? document.getElementById(groupId) : null;
select.innerHTML = '';
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt'];
const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const titles = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
@@ -639,6 +639,8 @@
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
const protoTitle = (key) => {
+629 -552
View File
File diff suppressed because it is too large Load Diff
+580 -495
View File
File diff suppressed because it is too large Load Diff
+580 -495
View File
File diff suppressed because it is too large Load Diff
+629 -552
View File
File diff suppressed because it is too large Load Diff
+580 -495
View File
File diff suppressed because it is too large Load Diff