Compare commits

...
17 Commits
Author SHA1 Message Date
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
orohiandCursor a9a0eea61d v1.7.0: pick server first, then protocol in create flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:00:09 +03:00
orohiandCursor 64aa1850a0 Proxy update checks through the panel to avoid CORS failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:55:07 +03:00
orohiandCursor b71c31605d Point update checker to git.evilfox.cc instead of GitHub.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:47:44 +03:00
20 changed files with 6659 additions and 2789 deletions
+65 -4
View File
@@ -1,6 +1,6 @@
# Amnezia Web Panel # 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 > ### 🔄 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. * **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. * **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. * **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. * **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. * **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**: * **🛠 Services**:
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings. * **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. * **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. * **NGINX + Let's Encrypt**: Reverse-proxy and HTTPS automation with certificate management for secure public endpoints.
* **⚙️ Core Server Management**: * **⚙️ Core Server Management**:
* **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically. * **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. * **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. * **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. * **Reboot** the server directly from the UI.
* Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback. * 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. * **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang.
* **🧩 Marketplace & Templates**: * **🧩 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. * Multi-protocol management lets you run and control multiple protocol instances on the same server.
* **🌐 Internationalization (i18n)**: * **🌐 Internationalization (i18n)**:
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**. * Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
* Native **RTL (Right-to-Left)** support for Persian language. * 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**: * **👥 Advanced User Management**:
* Role-based access (Admin, Support, Regular User). * Role-based access (Admin, Support, Regular User).
* Traffic limits, status monitoring, and account expiration. * Traffic limits, status monitoring, and account expiration.
@@ -219,16 +228,66 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### 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 → 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 ### 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). * **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. * **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. * **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
* **API performance**: in-memory data cache, faster server check/stats. * **API performance**: in-memory data cache, faster server check/stats.
* **Share / guest**: one-tap “Copy key”. * **Share / guest**: one-tap “Copy key”.
* **User expiration**: countdown can start on first config use; UTC-safe comparisons. * **User expiration**: countdown can start on first config use; UTC-safe comparisons.
* **UI**: shared SVG icon system. * **UI**: shared SVG icon system.
* **Removed earlier**: Hysteria 2.
## 🔧 Project Details ## 🔧 Project Details
@@ -290,6 +349,8 @@ web-panel/
│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy │ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy
│ ├── wireguard_manager.py # Classic WireGuard │ ├── wireguard_manager.py # Classic WireGuard
│ ├── xray_manager.py # Xray-core (VLESS-Reality) │ ├── 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 │ ├── telemt_manager.py # Telegram MTProxy
│ ├── dns_manager.py # AmneziaDNS (Unbound) │ ├── dns_manager.py # AmneziaDNS (Unbound)
│ ├── adguard_manager.py # AdGuard Home │ ├── adguard_manager.py # AdGuard Home
+549 -10
View File
@@ -46,6 +46,7 @@ from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager from managers.xray_manager import XrayManager
from managers.wireguard_manager import WireGuardManager from managers.wireguard_manager import WireGuardManager
from managers.backup_manager import BackupManager 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 import telegram_bot as tg_bot
# Configure logging # Configure logging
@@ -101,7 +102,9 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v1.6.0" CURRENT_VERSION = "v2.5.2"
RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) 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')) TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json'))
@@ -135,6 +138,7 @@ TUNNEL_RUNTIMES = {
TUNNEL_LOCK = threading.Lock() TUNNEL_LOCK = threading.Lock()
TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+') TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+')
WARP_CLI_COMMAND = 'warp-cli.exe' if os.name == 'nt' else 'warp-cli' WARP_CLI_COMMAND = 'warp-cli.exe' if os.name == 'nt' else 'warp-cli'
NORDVPN_CLI_COMMAND = 'nordvpn.exe' if os.name == 'nt' else 'nordvpn'
@@ -149,7 +153,7 @@ def load_translations():
if f.endswith('.json'): if f.endswith('.json'):
lang = f.split('.')[0] lang = f.split('.')[0]
try: 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) TRANSLATIONS[lang] = json.load(tf)
except Exception as e: except Exception as e:
logger.error(f"Error loading translation {f}: {e}") logger.error(f"Error loading translation {f}: {e}")
@@ -370,6 +374,152 @@ def disable_warp():
return get_warp_status() 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): def get_tunnel_command_name(provider: str):
if provider == 'cloudflare': if provider == 'cloudflare':
return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared' return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared'
@@ -841,8 +991,8 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20):
return get_tunnel_status(provider) return get_tunnel_status(provider)
BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx']
MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'} MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'}
def protocol_base(protocol: str) -> str: def protocol_base(protocol: str) -> str:
@@ -882,6 +1032,8 @@ def protocol_display_name(protocol: str) -> str:
'awg_legacy': 'AmneziaWG Legacy', 'awg_legacy': 'AmneziaWG Legacy',
'xray': 'Xray', 'xray': 'Xray',
'telemt': 'Telemt', 'telemt': 'Telemt',
'hysteria': 'Hysteria 2',
'naiveproxy': 'NaiveProxy',
'dns': 'AmneziaDNS', 'dns': 'AmneziaDNS',
'wireguard': 'WireGuard', 'wireguard': 'WireGuard',
'socks5': 'SOCKS5', 'socks5': 'SOCKS5',
@@ -901,6 +1053,8 @@ def protocol_container_name(protocol: str) -> Optional[str]:
'awg_legacy': 'amnezia-awg-legacy', 'awg_legacy': 'amnezia-awg-legacy',
'xray': 'amnezia-xray', 'xray': 'amnezia-xray',
'telemt': 'telemt', 'telemt': 'telemt',
'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy',
'dns': 'amnezia-dns', 'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard', 'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy', 'socks5': 'amnezia-socks5proxy',
@@ -940,6 +1094,12 @@ def get_protocol_manager(ssh, protocol: str):
elif base == 'nginx': elif base == 'nginx':
from managers.nginx_manager import NginxManager from managers.nginx_manager import NginxManager
return NginxManager(ssh, protocol) 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 from managers.awg_manager import AWGManager
return AWGManager(ssh) return AWGManager(ssh)
@@ -1001,7 +1161,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs):
# Protocols that own VPN clients (can list/link connections) # 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): def generate_vpn_link(config_text):
@@ -1663,6 +1823,8 @@ class AddServerRequest(BaseModel):
password: str = '' password: str = ''
private_key: str = '' private_key: str = ''
name: str = '' name: str = ''
ssl_domain: str = ''
ssl_email: str = ''
class EditServerRequest(BaseModel): class EditServerRequest(BaseModel):
@@ -1675,6 +1837,8 @@ class EditServerRequest(BaseModel):
# fields can be omitted to keep current auth unchanged. # fields can be omitted to keep current auth unchanged.
password: Optional[str] = None password: Optional[str] = None
private_key: Optional[str] = None private_key: Optional[str] = None
ssl_domain: Optional[str] = None
ssl_email: Optional[str] = None
class ReorderServersRequest(BaseModel): class ReorderServersRequest(BaseModel):
@@ -1704,6 +1868,12 @@ class InstallProtocolRequest(BaseModel):
# NGINX # NGINX
nginx_domain: Optional[str] = None nginx_domain: Optional[str] = None
nginx_email: 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): class Socks5SettingsRequest(BaseModel):
@@ -1713,6 +1883,13 @@ class Socks5SettingsRequest(BaseModel):
password: Optional[str] = None 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): class ProtocolRequest(BaseModel):
protocol: str = 'awg' protocol: str = 'awg'
@@ -1963,6 +2140,16 @@ class TunnelStartRequest(BaseModel):
authtoken: Optional[str] = None 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 ======================== # ======================== Startup ========================
@app.on_event("startup") @app.on_event("startup")
@@ -2270,7 +2457,7 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/') return RedirectResponse(url='/')
server = data['servers'][server_id] server = data['servers'][server_id]
users_list = data.get('users', []) users_list = data.get('users', [])
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list, all_servers=data['servers']) return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list)
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) @app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
@@ -2431,6 +2618,13 @@ async def api_add_server(request: Request, req: AddServerRequest):
'private_key': req.private_key, 'server_info': server_info, 'private_key': req.private_key, 'server_info': server_info,
'protocols': {}, '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 = load_data()
data['servers'].append(server) data['servers'].append(server)
save_data(data) save_data(data)
@@ -2491,9 +2685,27 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque
server['username'] = new_user server['username'] = new_user
server['password'] = new_pass server['password'] = new_pass
server['private_key'] = new_key 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) save_data(data)
return {'status': 'success', 'server_info': server_info} return {'status': 'success', 'server_info': info}
except Exception as e: except Exception as e:
logger.exception("Error editing server") logger.exception("Error editing server")
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
@@ -2799,6 +3011,18 @@ async def api_check_server(request: Request, server_id: int):
for key in ('domain', 'email', 'site_url'): for key in ('domain', 'email', 'site_url'):
if db_proto.get(key) not in (None, ''): if db_proto.get(key) not in (None, ''):
merged[key] = db_proto[key] 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 return merged
def should_preserve_saved_protocol(proto, result=None, err=None): def should_preserve_saved_protocol(proto, result=None, err=None):
@@ -2950,6 +3174,20 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
domain=req.nginx_domain, domain=req.nginx_domain,
email=req.nginx_email, 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: else:
result = manager.install_protocol(install_protocol, port=req.port) result = manager.install_protocol(install_protocol, port=req.port)
@@ -2976,6 +3214,29 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro
proto_record['domain'] = result.get('domain') proto_record['domain'] = result.get('domain')
proto_record['email'] = result.get('email') proto_record['email'] = result.get('email')
proto_record['site_url'] = result.get('site_url') 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['base_protocol'] = install_base
proto_record['instance'] = protocol_instance(install_protocol) proto_record['instance'] = protocol_instance(install_protocol)
proto_record['display_name'] = protocol_display_name(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol)
@@ -3051,6 +3312,83 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S
return JSONResponse({'error': str(e)}, status_code=500) 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"]) @app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"])
async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest):
if not _check_admin(request): if not _check_admin(request):
@@ -3084,6 +3422,8 @@ CONTAINER_NAMES = {
'awg_legacy': 'amnezia-awg-legacy', 'awg_legacy': 'amnezia-awg-legacy',
'xray': 'amnezia-xray', 'xray': 'amnezia-xray',
'telemt': 'telemt', 'telemt': 'telemt',
'hysteria': 'amnezia-hysteria',
'naiveproxy': 'amnezia-naiveproxy',
'dns': 'amnezia-dns', 'dns': 'amnezia-dns',
'wireguard': 'amnezia-wireguard', 'wireguard': 'amnezia-wireguard',
'socks5': 'amnezia-socks5proxy', 'socks5': 'amnezia-socks5proxy',
@@ -3390,13 +3730,39 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
ssh.run_sudo_command(f"docker stop {container}") ssh.run_sudo_command(f"docker stop {container}")
action = 'stopped' action = 'stopped'
else: 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' 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() ssh.disconnect()
return { return {
'status': 'success', 'status': 'success',
'action': action, 'action': action,
'container': container, 'container': container,
'error': error,
'recent_logs': recent_logs,
} }
except Exception as e: except Exception as e:
logger.exception("Error toggling container") logger.exception("Error toggling container")
@@ -3418,7 +3784,22 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog
container = protocol_container_name(req.protocol) container = protocol_container_name(req.protocol)
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() 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)) tail = max(20, min(int(req.tail or 200), 2000))
out, err, _ = ssh.run_sudo_command( out, err, _ = ssh.run_sudo_command(
f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
@@ -3539,6 +3920,14 @@ async def api_server_config(request: Request, server_id: int, req: ProtocolReque
from managers.nginx_manager import NginxManager from managers.nginx_manager import NginxManager
mgr = NginxManager(ssh, req.protocol) mgr = NginxManager(ssh, req.protocol)
config = mgr._get_server_config(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: else:
mgr = AWGManager(ssh) mgr = AWGManager(ssh)
config = mgr._get_server_config(req.protocol) config = mgr._get_server_config(req.protocol)
@@ -3583,6 +3972,14 @@ async def api_server_config_save(request: Request, server_id: int, req: ServerCo
from managers.nginx_manager import NginxManager from managers.nginx_manager import NginxManager
mgr = NginxManager(ssh, req.protocol) mgr = NginxManager(ssh, req.protocol)
mgr.save_server_config(req.protocol, req.config) 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: else:
mgr = AWGManager(ssh) mgr = AWGManager(ssh)
mgr.save_server_config(req.protocol, req.config) mgr.save_server_config(req.protocol, req.config)
@@ -5253,6 +5650,125 @@ async def api_get_settings(request: Request):
return data.get('settings', {}) return data.get('settings', {})
@app.get('/api/settings/check_updates', tags=["Settings"])
async def api_check_updates(request: Request):
"""Proxy latest release lookup to avoid browser CORS blocks on git.evilfox.cc."""
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,
headers={'Accept': 'application/json'},
)
if resp.status_code >= 400:
return JSONResponse(
{'error': f'Release API returned HTTP {resp.status_code}'},
status_code=502,
)
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 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"]) @app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request): async def api_tunnels_status(request: Request):
if not _check_admin(request): if not _check_admin(request):
@@ -5262,6 +5778,7 @@ async def api_tunnels_status(request: Request):
'cloudflare': get_tunnel_status('cloudflare'), 'cloudflare': get_tunnel_status('cloudflare'),
'ngrok': get_tunnel_status('ngrok'), 'ngrok': get_tunnel_status('ngrok'),
'warp': get_warp_status(), 'warp': get_warp_status(),
'nordvpn': get_nordvpn_status(),
} }
@@ -5347,6 +5864,28 @@ async def api_warp_disconnect(request: Request):
return JSONResponse({'error': str(e)}, status_code=500) 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') # @app.post('/api/settings/save')
# async def api_save_settings(request: Request, body: SaveSettingsRequest): # async def api_save_settings(request: Request, body: SaveSettingsRequest):
# _check_admin(request) # _check_admin(request)
+8
View File
@@ -62,6 +62,14 @@ class BackupManager:
remote_dir = inst_path('/opt/amnezia/telemt') remote_dir = inst_path('/opt/amnezia/telemt')
paths['host'] = [remote_dir] paths['host'] = [remote_dir]
paths['container'] = [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': elif base == 'dns':
paths['host'] = ['/opt/amnezia/dns'] paths['host'] = ['/opt/amnezia/dns']
paths['container'] = ['/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
+394
View File
@@ -0,0 +1,394 @@
"""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:
return os.environ.get('PANEL_UPDATE_ALLOW_DOCKER', '').strip().lower() in ('1', 'true', 'yes')
def _app_root_writable(self) -> bool:
probe = os.path.join(self.app_root, '.panel_update_write_probe')
try:
with open(probe, 'w', encoding='utf-8') as fh:
fh.write('ok')
os.remove(probe)
return True
except OSError:
return False
def detect_mode(self) -> dict:
frozen = bool(getattr(sys, 'frozen', False))
in_docker = 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()
+318
View File
@@ -944,6 +944,11 @@ a:hover {
color: #38bdf8; 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 { .protocol-dns .protocol-icon {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1));
color: var(--success); color: var(--success);
@@ -1618,6 +1623,319 @@ a:hover {
gap: var(--space-md); 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 { .mt-md {
margin-top: var(--space-md); margin-top: var(--space-md);
} }
+11 -3
View File
@@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None
_callback_refs = {} _callback_refs = {}
_pending_inputs = {} _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"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"}
@@ -137,6 +137,8 @@ def _protocol_display_name(protocol: str) -> str:
"awg_legacy": "AmneziaWG Legacy", "awg_legacy": "AmneziaWG Legacy",
"xray": "Xray", "xray": "Xray",
"telemt": "Telemt", "telemt": "Telemt",
"hysteria": "Hysteria 2",
"naiveproxy": "NaiveProxy",
"dns": "AmneziaDNS", "dns": "AmneziaDNS",
"wireguard": "WireGuard", "wireguard": "WireGuard",
"socks5": "SOCKS5", "socks5": "SOCKS5",
@@ -338,6 +340,8 @@ def _get_ssh_and_manager(server: dict, proto: str):
from managers.awg_manager import AWGManager from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager from managers.xray_manager import XrayManager
from managers.telemt_manager import TelemtManager 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.wireguard_manager import WireGuardManager
from managers.dns_manager import DNSManager from managers.dns_manager import DNSManager
from managers.socks5_manager import Socks5Manager from managers.socks5_manager import Socks5Manager
@@ -356,6 +360,10 @@ def _get_ssh_and_manager(server: dict, proto: str):
manager = XrayManager(ssh, proto) manager = XrayManager(ssh, proto)
elif base == "telemt": elif base == "telemt":
manager = TelemtManager(ssh, proto) manager = TelemtManager(ssh, proto)
elif base == "hysteria":
manager = HysteriaManager(ssh, proto)
elif base == "naiveproxy":
manager = NaiveProxyManager(ssh, proto)
elif base == "wireguard": elif base == "wireguard":
manager = WireGuardManager(ssh) manager = WireGuardManager(ssh)
elif base == "dns": 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") 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>") 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: if is_link_proto:
await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>") await api.send_message(chat_id, f"🔗 <b>Connection link</b> (tap to copy):\n<code>{_e(config)}</code>")
else: 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): 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>") 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>") await api.send_message(chat_id, f"🔗 <b>Connection link</b>:\n<code>{_e(config)}</code>")
else: else:
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{_e(config)}</pre>") 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 === 'awg_legacy') return 'AWG Legacy';
if (p === 'xray') return 'Xray'; if (p === 'xray') return 'Xray';
if (p === 'xui') return '3x-ui VLESS'; 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(); 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 }}" <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-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-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"> draggable="true">
<div class="server-meta"> <div class="server-meta">
<div class="server-icon">{{ icon('server') }}</div> <div class="server-icon">{{ icon('server') }}</div>
@@ -137,6 +139,16 @@
</div> </div>
</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"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel') <button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button> }}</button>
@@ -202,6 +214,16 @@
</div> </div>
</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"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel') <button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button> }}</button>
@@ -244,6 +266,8 @@
username: document.getElementById('serverUser').value, username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value, password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').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); const result = await apiCall('/api/servers/add', 'POST', data);
@@ -292,6 +316,8 @@
document.getElementById('editServerUser').value = card.dataset.username || ''; document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = ''; document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').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. // Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key'; const authIsKey = card.dataset.auth === 'key';
@@ -321,6 +347,8 @@
// Empty -> null means "leave unchanged" on the backend. // Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null, password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').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); await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success'); showToast(_('server_saved'), 'success');
+115 -29
View File
@@ -127,17 +127,22 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="inviteServer" onchange="onInviteServerChange()">
{% if xui_configured %}
<option value="xui">3x-ui</option>
{% endif %}
{% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div>
<div class="form-group" id="inviteProtoGroup">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()"> <select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
<option value="xui">3x-ui VLESS</option> <!-- filled by JS from selected server -->
{% for s in servers %}
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
<option value="{{ key }}|{{ server_idx }}">{{ s.name or s.host }} — {{ key }}</option>
{% endif %}
{% endfor %}
{% endfor %}
</select> </select>
</div> </div>
@@ -187,9 +192,30 @@
<script> <script>
const invitesData = {{ invites | tojson }}; const invitesData = {{ invites | tojson }};
const serversData = {{ servers | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }}; const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
const raw = String(key || '');
const base = raw.split('__')[0];
const title = PROTO_TITLES[base] || base.toUpperCase();
const m = raw.match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
}
function inviteUrl(token) { function inviteUrl(token) {
return `${window.location.origin}/invite/${token}`; return `${window.location.origin}/invite/${token}`;
@@ -200,6 +226,57 @@
showToast(_('copied'), 'success'); showToast(_('copied'), 'success');
} }
function fillInviteProtocols(preferProtocol) {
const serverVal = document.getElementById('inviteServer').value;
const sel = document.getElementById('inviteProtocol');
sel.innerHTML = '';
if (serverVal === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = PROTO_TITLES.xui;
sel.appendChild(opt);
updateInviteProtoFields();
return;
}
const server = serversData[parseInt(serverVal, 10)];
const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(k => {
const info = protocols[k] || {};
if (!info.installed) return false;
return VPN_PROTO_ORDER.includes(k.split('__')[0]);
});
installed.sort((a, b) => {
const ia = VPN_PROTO_ORDER.indexOf(a.split('__')[0]);
const ib = VPN_PROTO_ORDER.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
if (!installed.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = _('no_protocols');
opt.disabled = true;
sel.appendChild(opt);
updateInviteProtoFields();
return;
}
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = protoTitle(key);
if (preferProtocol && preferProtocol === key) opt.selected = true;
sel.appendChild(opt);
});
updateInviteProtoFields();
}
function onInviteServerChange() {
fillInviteProtocols();
}
function updateInviteProtoFields() { function updateInviteProtoFields() {
const v = document.getElementById('inviteProtocol').value; const v = document.getElementById('inviteProtocol').value;
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none'; document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
@@ -237,25 +314,26 @@
} }
} }
function parseProtocolValue() { function parseInviteSelection() {
const raw = document.getElementById('inviteProtocol').value; const serverVal = document.getElementById('inviteServer').value;
if (raw === 'xui') return { protocol: 'xui', server_id: 0 }; const protocol = document.getElementById('inviteProtocol').value;
if (raw.includes('|')) { if (serverVal === 'xui' || protocol === 'xui') {
const [protocol, sid] = raw.split('|'); return { protocol: 'xui', server_id: 0 };
return { protocol, server_id: parseInt(sid || '0') };
} }
return { protocol: raw, server_id: 0 }; return { protocol, server_id: parseInt(serverVal || '0', 10) || 0 };
} }
function setProtocolSelect(protocol, serverId) { function setInviteSelection(protocol, serverId) {
const sel = document.getElementById('inviteProtocol'); const serverSel = document.getElementById('inviteServer');
if (protocol === 'xui') sel.value = 'xui'; if (protocol === 'xui') {
else { if ([...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
const want = `${protocol}|${serverId || 0}`; fillInviteProtocols('xui');
if ([...sel.options].some(o => o.value === want)) sel.value = want; return;
else sel.value = 'xui';
} }
updateInviteProtoFields(); const sid = String(serverId || 0);
if ([...serverSel.options].some(o => o.value === sid)) serverSel.value = sid;
else if (serverSel.options.length) serverSel.selectedIndex = xuiConfigured ? 1 : 0;
fillInviteProtocols(protocol);
} }
function openCreateInvite() { function openCreateInvite() {
@@ -269,12 +347,17 @@
document.getElementById('inviteNote').value = ''; document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none'; document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none'; document.getElementById('inviteResetUsedWrap').style.display = 'none';
setProtocolSelect('xui', 0); const serverSel = document.getElementById('inviteServer');
if (xuiConfigured && [...serverSel.options].some(o => o.value === 'xui')) serverSel.value = 'xui';
else if (serverSel.options.length) serverSel.selectedIndex = 0;
fillInviteProtocols();
if (xuiDefaultPanelId) { if (xuiDefaultPanelId) {
const p = document.getElementById('inviteXuiPanel'); const p = document.getElementById('inviteXuiPanel');
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId; if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
} }
loadInviteInbounds(xuiDefaultInbound); if (document.getElementById('inviteProtocol').value === 'xui') {
loadInviteInbounds(xuiDefaultInbound);
}
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -293,12 +376,14 @@
document.getElementById('inviteClearPassword').checked = false; document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block'; document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false; document.getElementById('inviteResetUsed').checked = false;
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0); setInviteSelection(inv.protocol || 'xui', inv.server_id || 0);
const panelSel = document.getElementById('inviteXuiPanel'); const panelSel = document.getElementById('inviteXuiPanel');
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) { if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
panelSel.value = inv.xui_panel_id; panelSel.value = inv.xui_panel_id;
} }
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound); if ((inv.protocol || '') === 'xui') {
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
}
openModal('inviteModal'); openModal('inviteModal');
} }
@@ -309,7 +394,8 @@
spinner.classList.remove('hidden'); spinner.classList.remove('hidden');
try { try {
const editId = document.getElementById('inviteEditId').value; const editId = document.getElementById('inviteEditId').value;
const proto = parseProtocolValue(); const proto = parseInviteSelection();
if (!proto.protocol) throw new Error(_('no_protocols'));
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0'); const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required')); if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
+3 -2
View File
@@ -118,10 +118,11 @@
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; 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 vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn'); 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'; vpnTab.style.display = 'none';
} else { } else {
vpnTab.style.display = ''; vpnTab.style.display = '';
+367 -10
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/icons.html" import icon %} {% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ server.name }}{% endblock %} {% block title_extra %} — {{ server.name }}{% endblock %}
@@ -295,6 +295,55 @@
</div> </div>
</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 --> <!-- WireGuard Card -->
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard"> <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);"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
@@ -657,6 +706,47 @@
<div class="form-hint">{{ _('nginx_install_hint') }}</div> <div class="form-hint">{{ _('nginx_install_hint') }}</div>
</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"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button> <button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn"> <button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
@@ -711,6 +801,45 @@
</div> </div>
</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 ===== --> <!-- ===== NGINX Site Modal ===== -->
<div class="modal-backdrop" id="siteConfigModal"> <div class="modal-backdrop" id="siteConfigModal">
<div class="modal" style="max-width:680px;"> <div class="modal" style="max-width:680px;">
@@ -859,6 +988,7 @@
<div class="config-panel active" id="panel-conf"> <div class="config-panel active" id="panel-conf">
<div class="config-display"> <div class="config-display">
<div class="config-text" id="configText"></div> <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"> <div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1"> <button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy') }} {{ _('copy') }}
@@ -961,12 +1091,16 @@
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
const MARKETPLACE_APPS = [ const MARKETPLACE_APPS = [
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' }, { 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', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_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: '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: '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: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_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' }, { proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
@@ -1002,7 +1136,7 @@
} }
function isMultiInstanceBase(proto) { 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) { function nextSuggestedPort(base, fallback) {
@@ -1207,6 +1341,8 @@
case 'awg_legacy': title = 'AmneziaWG Legacy'; break; case 'awg_legacy': title = 'AmneziaWG Legacy'; break;
case 'xray': title = 'Xray'; break; case 'xray': title = 'Xray'; break;
case 'telemt': title = 'Telemt'; break; case 'telemt': title = 'Telemt'; break;
case 'hysteria': title = 'Hysteria 2'; break;
case 'naiveproxy': title = 'NaiveProxy'; break;
case 'wireguard': title = 'WireGuard'; break; case 'wireguard': title = 'WireGuard'; break;
case 'dns': title = 'AmneziaDNS'; break; case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break; case 'socks5': title = 'SOCKS5 Proxy'; break;
@@ -1266,7 +1402,7 @@
for (const [proto, info] of orderedProtocolEntries(data.protocols)) { for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
updateProtocolCard(proto, info); 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) { if (info.container_running && isVPN) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = proto; opt.value = proto;
@@ -1494,7 +1630,13 @@
if (isService) { if (isService) {
grid = buildServiceInfoGrid(proto, info); grid = buildServiceInfoGrid(proto, info);
} else if (info.port) { } 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) { 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>`; 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 +1671,21 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button> <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> <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 { } else {
actionsEl.innerHTML = ` 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="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
@@ -1565,6 +1722,7 @@
} else if (info.container_exists) { } else if (info.container_exists) {
installedProtocols[proto] = true; installedProtocols[proto] = true;
const errText = String(info.error || '').trim(); 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 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" 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>`; : `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
@@ -1583,6 +1741,25 @@
if (errText) { 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>`; 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; infoGrid.innerHTML = grid;
} }
if (isService) { if (isService) {
@@ -1592,6 +1769,13 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</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> <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 { } else {
actionsEl.innerHTML = ` actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button> <button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
@@ -1872,6 +2056,22 @@
hint.innerHTML = `${_('nginx_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`; 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) { function openInstallModal(proto, installAnother = false) {
const base = protoBase(proto); const base = protoBase(proto);
currentInstallProto = installAnother ? base : proto; currentInstallProto = installAnother ? base : proto;
@@ -1887,11 +2087,15 @@
const socks5Opts = document.getElementById('socks5Options'); const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions'); const adguardOpts = document.getElementById('adguardOptions');
const nginxOpts = document.getElementById('nginxOptions'); const nginxOpts = document.getElementById('nginxOptions');
const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
telemtOpts.style.display = 'none'; telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none'; socks5Opts.style.display = 'none';
adguardOpts.style.display = 'none'; adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none'; nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none';
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
if (portGroup) portGroup.style.display = ''; if (portGroup) portGroup.style.display = '';
if (base === 'dns') { if (base === 'dns') {
@@ -1928,7 +2132,37 @@
portInput.disabled = false; portInput.disabled = false;
portHint.textContent = _('nginx_port_hint'); portHint.textContent = _('nginx_port_hint');
nginxOpts.style.display = 'block'; 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(); 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 { } else {
portLabel.textContent = _('port') + ' (UDP)'; portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424'; portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
@@ -1995,6 +2229,17 @@
} else if (protoBase(currentInstallProto) === 'nginx') { } else if (protoBase(currentInstallProto) === 'nginx') {
params.nginx_domain = document.getElementById('installNginxDomain').value.trim(); params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
params.nginx_email = document.getElementById('installNginxEmail').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); const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
clearInterval(progressInterval); clearInterval(progressInterval);
@@ -2210,6 +2455,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 ========== // ========== SOCKS5 Settings ==========
async function openSocks5Settings(proto = 'socks5') { async function openSocks5Settings(proto = 'socks5') {
currentSocks5Proto = proto || 'socks5'; currentSocks5Proto = proto || 'socks5';
@@ -2294,7 +2640,7 @@
const sent = userData.dataSent || ''; const sent = userData.dataSent || '';
const initial = name.charAt(0).toUpperCase(); const initial = name.charAt(0).toUpperCase();
const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false); 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 || ''; const assignedUser = client.assigned_user || '';
let metaHtml = ''; let metaHtml = '';
@@ -2332,7 +2678,7 @@
</div> </div>
</div> </div>
<div class="client-actions"> <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>` : ''} ${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-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> <button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">${uiIcon('trash')}</button>
@@ -2384,8 +2730,8 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; document.getElementById('vpnLinkText').textContent = currentVpnLink;
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.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' : '');
switchConfigTab('conf'); switchConfigTab('conf');
openModal('configModal'); openModal('configModal');
generateQR(result.config); generateQR(result.config);
@@ -2463,9 +2809,9 @@
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
// Restore tabs visibility first // 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.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 = ''; document.querySelectorAll('.config-tab')[2].style.display = '';
// Client was created via native Amnezia app — private key is not stored server-side // Client was created via native Amnezia app — private key is not stored server-side
@@ -2497,6 +2843,16 @@
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
document.getElementById('configText').textContent = result.config; document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink; 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'); switchConfigTab('conf');
openModal('configModal'); openModal('configModal');
generateQR(result.config); generateQR(result.config);
@@ -2609,6 +2965,7 @@
} }
// ========== Init ========== // ========== Init ==========
applyInstalledAppsVisibility(); applyInstalledAppsVisibility();
checkServer(); checkServer();
+457 -124
View File
@@ -110,21 +110,26 @@
<div id="guestCreateFields" <div id="guestCreateFields"
style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);"> style="{% if not settings.guest.allow_create %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md);">
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label> <label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()"> <select class="form-select" id="guest_create_server" onchange="fillGuestProtocols()">
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option> {% if xui_servers %}
{% for s in servers %} <option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui</option>
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
{% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt'] %}
<option value="{{ key }}|{{ server_idx }}"
{% if settings.guest.create_protocol == key and settings.guest.create_server_id == server_idx %}selected{% endif %}>
{{ s.name or s.host }} — {{ key }}
</option>
{% endif %} {% endif %}
{% endfor %} {% for s in servers %}
<option value="{{ loop.index0 }}"
{% if settings.guest.create_protocol != 'xui' and settings.guest.create_server_id == loop.index0 %}selected{% endif %}>
{{ s.name or s.host }} ({{ s.host }})
</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
<!-- filled by JS -->
</select>
<input type="hidden" id="guest_create_protocol_pref" value="{{ settings.guest.create_protocol or '' }}">
</div> </div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}"> <div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label> <label class="form-label">{{ _('xui_server_select_label') }}</label>
@@ -199,88 +204,147 @@
</div> </div>
<!-- BLOCK Tunnels --> <!-- BLOCK Tunnels -->
<div class="card"> <div class="card tunnels-card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3> <div class="tunnels-header">
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </div>
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span> <div class="tunnels-local">
</div> <div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;"> <div class="tunnels-local-body">
<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="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> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div> </div>
</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 class="tunnels-section">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<strong>Cloudflare Quick Tunnel</strong> <div class="tunnels-grid">
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span> <article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
</div> <div class="tunnel-card-head">
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> <div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-meta">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')"> <h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')"> </div>
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span> <div class="tunnel-card-body">
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div> <div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</button> </div>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')"> <div class="tunnel-card-actions tunnel-card-actions--triple">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span> <button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div> <span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
</button> <div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</div> </button>
</div> <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);"> <article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <div class="tunnel-card-head">
<strong>ngrok Tunnel + Authtoken</strong> <div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span> <div class="tunnel-card-meta">
</div> <h5 class="tunnel-card-title">ngrok</h5>
<div class="form-group" style="margin-bottom: var(--space-sm);"> <p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off"> </div>
</div> <span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> </div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-body">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')"> <input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')"> <button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span> <span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')"> <button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span> <span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button> </button>
</div> <button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
</div> <span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
<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>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </article>
<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>
</div> </div>
</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> </div>
<!-- BLOCK SSL/HTTPS --> <!-- BLOCK SSL/HTTPS -->
@@ -592,15 +656,24 @@
<!-- Updated via JS --> <!-- Updated via JS -->
</div> </div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);"> <div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;"> <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> <span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<a href="https://github.com/PRVTPRO/Amnezia-Web-Panel/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') }} ⬇️ {{ _('download_update') }}
</a> </a>
</div> </div>
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -911,31 +984,45 @@
const el = document.getElementById(`${provider}PublicUrls`); const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return; if (!el) return;
if (!urls || !urls.length) { if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url'); el.textContent = _('tunnel_no_public_url');
return; return;
} }
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => ` el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};"> <div class="tunnel-url-row">
<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> <code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div> </div>
`).join(''); `).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) { function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`); const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`); const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`); const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`); const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`); const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return; if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary'; const running = !!status.running;
badge.textContent = status.installed ? _('installed') : _('not_installed'); setTunnelStatusPill(badge, {
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable')); ok: running,
if (startBtn) startBtn.disabled = !!status.running; info: !running && status.installed,
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running); 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 (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] : [])); renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
} }
@@ -946,6 +1033,7 @@
updateTunnelProvider('cloudflare', data.cloudflare || {}); updateTunnelProvider('cloudflare', data.cloudflare || {});
updateTunnelProvider('ngrok', data.ngrok || {}); updateTunnelProvider('ngrok', data.ngrok || {});
updateWarpStatus(data.warp || {}); updateWarpStatus(data.warp || {});
updateNordvpnStatus(data.nordvpn || {});
} catch (err) { } catch (err) {
showToast(`${_('error')}: ` + err.message, 'error'); showToast(`${_('error')}: ` + err.message, 'error');
} }
@@ -958,12 +1046,16 @@
const connectBtn = document.getElementById('warpConnectBtn'); const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn'); const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText'); const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`; const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -971,6 +1063,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required'); connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectWarp() { async function connectWarp() {
@@ -1015,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) { async function enableTunnel(provider) {
const btn = document.getElementById(`${provider}TunnelBtn`); const btn = document.getElementById(`${provider}TunnelBtn`);
const spinner = document.getElementById(`${provider}TunnelSpinner`); const spinner = document.getElementById(`${provider}TunnelSpinner`);
@@ -1275,13 +1448,14 @@
let createProtocol = 'xui'; let createProtocol = 'xui';
let createServerId = 0; let createServerId = 0;
const guestProtoRaw = document.getElementById('guest_create_protocol').value; const guestServerVal = document.getElementById('guest_create_server')?.value || 'xui';
if (guestProtoRaw === 'xui') { const guestProtoVal = document.getElementById('guest_create_protocol')?.value || '';
if (guestServerVal === 'xui' || guestProtoVal === 'xui') {
createProtocol = 'xui'; createProtocol = 'xui';
} else if (guestProtoRaw.includes('|')) { createServerId = 0;
const parts = guestProtoRaw.split('|'); } else {
createProtocol = parts[0]; createProtocol = guestProtoVal;
createServerId = parseInt(parts[1] || '0'); createServerId = parseInt(guestServerVal || '0', 10) || 0;
} }
const guest = { const guest = {
@@ -1314,6 +1488,70 @@
} }
} }
const guestServersData = {{ servers | default([]) | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
function protoTitle(key) {
const raw = String(key || '');
const base = raw.split('__')[0];
const title = PROTO_TITLES[base] || base.toUpperCase();
const m = raw.match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
}
function fillGuestProtocols() {
const serverSel = document.getElementById('guest_create_server');
const protoSel = document.getElementById('guest_create_protocol');
if (!serverSel || !protoSel) return;
const prefer = document.getElementById('guest_create_protocol_pref')?.value || protoSel.value || '';
const serverVal = serverSel.value;
protoSel.innerHTML = '';
if (serverVal === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = PROTO_TITLES.xui;
protoSel.appendChild(opt);
updateGuestCreateFields();
return;
}
const server = guestServersData[parseInt(serverVal, 10)];
const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(k => {
const info = protocols[k] || {};
return !!info.installed && VPN_PROTO_ORDER.includes(k.split('__')[0]);
}).sort((a, b) => {
const ia = VPN_PROTO_ORDER.indexOf(a.split('__')[0]);
const ib = VPN_PROTO_ORDER.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
if (!installed.length) {
const opt = document.createElement('option');
opt.value = '';
opt.disabled = true;
opt.textContent = _('no_protocols');
protoSel.appendChild(opt);
} else {
installed.forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = protoTitle(key);
if (prefer && prefer === key) opt.selected = true;
protoSel.appendChild(opt);
});
}
updateGuestCreateFields();
}
function updateGuestCreateFields() { function updateGuestCreateFields() {
const v = document.getElementById('guest_create_protocol')?.value; const v = document.getElementById('guest_create_protocol')?.value;
const group = document.getElementById('guestInboundGroup'); const group = document.getElementById('guestInboundGroup');
@@ -1321,6 +1559,7 @@
if (v === 'xui') loadGuestInbounds(); if (v === 'xui') loadGuestInbounds();
} }
fillGuestProtocols();
if (document.getElementById('guest_create_protocol')?.value === 'xui') { if (document.getElementById('guest_create_protocol')?.value === 'xui') {
loadGuestInbounds(); loadGuestInbounds();
} }
@@ -1411,47 +1650,141 @@
updateProtocolsForSync(); updateProtocolsForSync();
} }
async function checkForUpdates() { let latestUpdateInfo = null;
async function checkForUpdates(silent) {
const btn = document.getElementById('checkUpdateBtn'); const btn = document.getElementById('checkUpdateBtn');
const text = document.getElementById('checkUpdateBtnText'); const text = document.getElementById('checkUpdateBtnText');
const spinner = document.getElementById('checkUpdateSpinner'); const spinner = document.getElementById('checkUpdateSpinner');
const statusDiv = document.getElementById('updateStatus'); const statusDiv = document.getElementById('updateStatus');
const downloadBtn = document.getElementById('downloadUpdateBtn'); const downloadBtn = document.getElementById('downloadUpdateBtn');
const applyBtn = document.getElementById('applyUpdateBtn');
btn.disabled = true; const modeHint = document.getElementById('updateModeHint');
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden'); if (!silent) {
btn.disabled = true;
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
spinner.classList.remove('hidden');
}
statusDiv.classList.add('hidden'); statusDiv.classList.add('hidden');
downloadBtn.classList.add('hidden'); downloadBtn.classList.add('hidden');
applyBtn.classList.add('hidden');
try { try {
const response = await fetch('https://api.github.com/repos/PRVTPRO/Amnezia-Web-Panel/releases/latest'); const data = await apiCall('/api/settings/check_updates');
if (!response.ok) throw new Error('API Error'); latestUpdateInfo = data;
const data = await response.json(); const latestVersion = data.latest_version || '';
const latestVersion = data.tag_name;
const currentVersion = '{{ current_version }}';
statusDiv.classList.remove('hidden'); statusDiv.classList.remove('hidden');
if (latestVersion !== currentVersion && latestVersion) { 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 = !data.can_auto_update;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
}
if (data.update_available && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`; statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)'; statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url; downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden'); downloadBtn.classList.remove('hidden');
if (data.can_auto_update) {
applyBtn.classList.remove('hidden');
}
} else { } else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`; statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)'; statusDiv.style.border = '1px solid var(--border-color)';
} }
} catch (err) { } catch (err) {
showToast('Failed to check for updates: ' + err.message, 'error'); if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
} finally { } finally {
btn.disabled = false; if (!silent) {
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}"; btn.disabled = false;
spinner.classList.add('hidden'); 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 ========== */ /* ========== API Tokens ========== */
function fmtTokenDate(iso) { function fmtTokenDate(iso) {
+60 -20
View File
@@ -143,9 +143,10 @@
<select class="form-select" id="newUserServer"> <select class="form-select" id="newUserServer">
<option value="">{{ _('no_create_conn') }}</option> <option value="">{{ _('no_create_conn') }}</option>
{% for s in servers %} {% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name }} ({{ s.host }})</option> <option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div> </div>
<div class="form-group" id="newUserProtoGroup" style="display:none;"> <div class="form-group" id="newUserProtoGroup" style="display:none;">
@@ -293,10 +294,14 @@
<div class="form-group" id="ucServerGroup"> <div class="form-group" id="ucServerGroup">
<label class="form-label">{{ _('server_label') }}</label> <label class="form-label">{{ _('server_label') }}</label>
<select class="form-select" id="ucServer"> <select class="form-select" id="ucServer">
{% if xui_servers %}
<option value="xui">3x-ui</option>
{% endif %}
{% for s in servers %} {% for s in servers %}
<option value="{{ loop.index0 }}">{{ s.name }} ({{ s.host }})</option> <option value="{{ loop.index0 }}">{{ s.name or s.host }} ({{ s.host }})</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-hint">{{ _('server_then_protocol_hint') }}</div>
</div> </div>
<div class="form-group" id="ucProtoGroup"> <div class="form-group" id="ucProtoGroup">
@@ -626,30 +631,57 @@
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
const group = groupId ? document.getElementById(groupId) : null; const group = groupId ? document.getElementById(groupId) : null;
select.innerHTML = ''; select.innerHTML = '';
const vpnBases = new Set(['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']); const vpnOrder = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy'];
const titles = {
awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy',
wireguard: 'WireGuard',
xray: 'Xray (VLESS-Reality)',
telemt: 'Telemt',
hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy',
xui: '3x-ui VLESS',
};
const protoTitle = (key) => {
const base = String(key || '').split('__')[0];
const title = titles[base] || base.toUpperCase();
const m = String(key || '').match(/__(\d+)$/);
return m ? `${title} #${m[1]}` : title;
};
let count = 0; let count = 0;
// Special source: 3x-ui (server picker value "xui")
if (String(serverId) === 'xui') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = titles.xui;
select.appendChild(opt);
count = 1;
if (group) group.style.display = '';
if (selectId === 'ucProtocol') updateXuiInboundVisibility();
return;
}
const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null; const server = (serverId !== '' && serversData[serverId]) ? serversData[serverId] : null;
const protocols = (server && server.protocols) || {}; const protocols = (server && server.protocols) || {};
const installed = Object.keys(protocols).filter(key => {
const info = protocols[key] || {};
if (!info.installed) return false;
return vpnOrder.includes(key.split('__')[0]);
}).sort((a, b) => {
const ia = vpnOrder.indexOf(a.split('__')[0]);
const ib = vpnOrder.indexOf(b.split('__')[0]);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
});
for (const [key, info] of Object.entries(protocols)) { installed.forEach(key => {
if (!info.installed) continue;
const base = key.split('__')[0];
if (!vpnBases.has(base)) continue;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = key; opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : (key === 'wireguard' ? 'WireGuard' : (key === 'telemt' ? 'Telemt' : key.toUpperCase()))))); opt.textContent = protoTitle(key);
select.appendChild(opt); select.appendChild(opt);
count++; count++;
} });
if (xuiConfigured && selectId === 'ucProtocol') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = '3x-ui VLESS';
select.appendChild(opt);
count++;
}
if (count > 0) { if (count > 0) {
if (group) group.style.display = ''; if (group) group.style.display = '';
@@ -724,9 +756,12 @@
document.getElementById('ucServer').addEventListener('change', (e) => { document.getElementById('ucServer').addEventListener('change', (e) => {
updateProtocols(e.target.value, 'ucProtocol'); updateProtocols(e.target.value, 'ucProtocol');
if (document.getElementById('ucMode').value === 'existing') { const mode = document.getElementById('ucMode').value;
if (mode === 'existing' && e.target.value !== 'xui') {
fetchExistingClients(); fetchExistingClients();
} }
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
updateXuiInboundVisibility();
}); });
document.getElementById('ucProtocol').addEventListener('change', (e) => { document.getElementById('ucProtocol').addEventListener('change', (e) => {
@@ -898,13 +933,18 @@
spinner.classList.remove('hidden'); spinner.classList.remove('hidden');
try { try {
const serverRaw = document.getElementById('ucServer').value;
const protocol = document.getElementById('ucProtocol').value;
const body = { const body = {
server_id: parseInt(document.getElementById('ucServer').value) || 0, server_id: serverRaw === 'xui' ? 0 : (parseInt(serverRaw, 10) || 0),
protocol: document.getElementById('ucProtocol').value, protocol: protocol,
name: document.getElementById('ucName').value || 'VPN Connection', name: document.getElementById('ucName').value || 'VPN Connection',
}; };
if (mode === 'existing') { if (mode === 'existing') {
if (serverRaw === 'xui' || protocol === 'xui') {
throw new Error(_('link_existing_xui_unsupported') || 'Link existing is only for Amnezia server protocols');
}
const clientId = document.getElementById('ucExistingClient').value; const clientId = document.getElementById('ucExistingClient').value;
if (!clientId) throw new Error(_('select_connection')); if (!clientId) throw new Error(_('select_connection'));
body.client_id = clientId; body.client_id = clientId;
+616 -551
View File
File diff suppressed because it is too large Load Diff
+567 -495
View File
File diff suppressed because it is too large Load Diff
+567 -495
View File
File diff suppressed because it is too large Load Diff
+616 -551
View File
File diff suppressed because it is too large Load Diff
+567 -495
View File
File diff suppressed because it is too large Load Diff