diff --git a/README.md b/README.md index cdcf386..1f45524 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Amnezia Web Panel -A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities. +A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **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. > ### 🔄 Compatibility with Official Amnezia Client > @@ -62,6 +62,7 @@ Configuration panel for system parameters and preferences: * **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients. * **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support. * **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation. + * **Hysteria 2**: QUIC/HTTP3 proxy from [apernet/hysteria](https://github.com/apernet/hysteria) — Let's Encrypt TLS, salamander obfuscation, password auth, `hy2://` share links. * **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. * **🛠 Services**: @@ -223,6 +224,7 @@ GitHub Actions workflows in `.github/workflows/`: * **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). * 3x-ui is a separate “server” choice; VLESS inbounds still load from that panel’s API. +* **Hysteria 2** restored ([apernet/hysteria](https://github.com/apernet/hysteria)) via official `tobyxdd/hysteria:v2` image, Let’s Encrypt domain TLS, selectable UDP port, `hy2://` client links. ### 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). @@ -233,7 +235,6 @@ GitHub Actions workflows in `.github/workflows/`: * **Share / guest**: one-tap “Copy key”. * **User expiration**: countdown can start on first config use; UTC-safe comparisons. * **UI**: shared SVG icon system. -* **Removed earlier**: Hysteria 2. ## 🔧 Project Details @@ -295,6 +296,7 @@ web-panel/ │ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy │ ├── wireguard_manager.py # Classic WireGuard │ ├── xray_manager.py # Xray-core (VLESS-Reality) +│ ├── hysteria_manager.py # Hysteria 2 (apernet/hysteria) │ ├── telemt_manager.py # Telegram MTProxy │ ├── dns_manager.py # AmneziaDNS (Unbound) │ ├── adguard_manager.py # AdGuard Home diff --git a/app.py b/app.py index c5b2f5c..e03762c 100644 --- a/app.py +++ b/app.py @@ -843,8 +843,8 @@ async def wait_for_tunnel_url(provider: str, seconds: int = 20): return get_tunnel_status(provider) -BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] -MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'} +BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] +MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'socks5'} def protocol_base(protocol: str) -> str: @@ -884,6 +884,7 @@ def protocol_display_name(protocol: str) -> str: 'awg_legacy': 'AmneziaWG Legacy', 'xray': 'Xray', 'telemt': 'Telemt', + 'hysteria': 'Hysteria 2', 'dns': 'AmneziaDNS', 'wireguard': 'WireGuard', 'socks5': 'SOCKS5', @@ -903,6 +904,7 @@ def protocol_container_name(protocol: str) -> Optional[str]: 'awg_legacy': 'amnezia-awg-legacy', 'xray': 'amnezia-xray', 'telemt': 'telemt', + 'hysteria': 'amnezia-hysteria', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -942,6 +944,9 @@ def get_protocol_manager(ssh, protocol: str): elif base == 'nginx': from managers.nginx_manager import NginxManager return NginxManager(ssh, protocol) + elif base == 'hysteria': + from managers.hysteria_manager import HysteriaManager + return HysteriaManager(ssh, protocol) from managers.awg_manager import AWGManager return AWGManager(ssh) @@ -1003,7 +1008,7 @@ def _manager_call(manager, method, protocol, *args, **kwargs): # Protocols that own VPN clients (can list/link connections) -CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'xui'} +CLIENT_VPN_BASES = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard', 'hysteria', 'xui'} def generate_vpn_link(config_text): @@ -1665,6 +1670,8 @@ class AddServerRequest(BaseModel): password: str = '' private_key: str = '' name: str = '' + ssl_domain: str = '' + ssl_email: str = '' class EditServerRequest(BaseModel): @@ -1677,6 +1684,8 @@ class EditServerRequest(BaseModel): # fields can be omitted to keep current auth unchanged. password: Optional[str] = None private_key: Optional[str] = None + ssl_domain: Optional[str] = None + ssl_email: Optional[str] = None class ReorderServersRequest(BaseModel): @@ -1706,6 +1715,9 @@ class InstallProtocolRequest(BaseModel): # NGINX nginx_domain: Optional[str] = None nginx_email: Optional[str] = None + # Hysteria + hysteria_domain: Optional[str] = None + hysteria_email: Optional[str] = None class Socks5SettingsRequest(BaseModel): @@ -1715,6 +1727,14 @@ class Socks5SettingsRequest(BaseModel): password: Optional[str] = None +class HysteriaSettingsRequest(BaseModel): + protocol: str = 'hysteria' + port: Optional[int] = None + domain: Optional[str] = None + email: Optional[str] = None + renew_ssl: bool = False + + class ProtocolRequest(BaseModel): protocol: str = 'awg' @@ -2433,6 +2453,13 @@ async def api_add_server(request: Request, req: AddServerRequest): 'private_key': req.private_key, 'server_info': server_info, 'protocols': {}, } + ssl_domain = (req.ssl_domain or '').strip().lower() + ssl_email = (req.ssl_email or '').strip() + if ssl_domain: + server_info['ssl_domain'] = ssl_domain + if ssl_email: + server_info['ssl_email'] = ssl_email + server['server_info'] = server_info data = load_data() data['servers'].append(server) save_data(data) @@ -2493,9 +2520,27 @@ async def api_edit_server(request: Request, server_id: int, req: EditServerReque server['username'] = new_user server['password'] = new_pass server['private_key'] = new_key - server['server_info'] = server_info + # Merge SSH probe info with preserved SSL domain defaults + info = dict(server.get('server_info') or {}) + if isinstance(server_info, dict): + for k, v in server_info.items(): + if k not in ('ssl_domain', 'ssl_email'): + info[k] = v + if req.ssl_domain is not None: + domain = (req.ssl_domain or '').strip().lower() + if domain: + info['ssl_domain'] = domain + else: + info.pop('ssl_domain', None) + if req.ssl_email is not None: + email = (req.ssl_email or '').strip() + if email: + info['ssl_email'] = email + else: + info.pop('ssl_email', None) + server['server_info'] = info save_data(data) - return {'status': 'success', 'server_info': server_info} + return {'status': 'success', 'server_info': info} except Exception as e: logger.exception("Error editing server") return JSONResponse({'error': str(e)}, status_code=500) @@ -2801,6 +2846,12 @@ async def api_check_server(request: Request, server_id: int): for key in ('domain', 'email', 'site_url'): if db_proto.get(key) not in (None, ''): merged[key] = db_proto[key] + if protocol_base(proto) == 'hysteria': + for key in ('domain', 'email'): + if db_proto.get(key) not in (None, '') and not merged.get(key): + merged[key] = db_proto[key] + if db_proto.get('port') and not merged.get('port'): + merged['port'] = db_proto['port'] return merged def should_preserve_saved_protocol(proto, result=None, err=None): @@ -2952,6 +3003,13 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro domain=req.nginx_domain, email=req.nginx_email, ) + elif install_base == 'hysteria': + result = manager.install_protocol( + protocol_type=install_protocol, + port=req.port, + domain=req.hysteria_domain, + email=req.hysteria_email, + ) else: result = manager.install_protocol(install_protocol, port=req.port) @@ -2978,6 +3036,18 @@ async def api_install_protocol(request: Request, server_id: int, req: InstallPro proto_record['domain'] = result.get('domain') proto_record['email'] = result.get('email') proto_record['site_url'] = result.get('site_url') + if install_base == 'hysteria': + # Remember domain/email as server defaults for next installs / renewals + info = server.setdefault('server_info', {}) + if req.hysteria_domain: + info['ssl_domain'] = (req.hysteria_domain or '').strip().lower() + if req.hysteria_email: + info['ssl_email'] = (req.hysteria_email or '').strip() + save_data(data) + proto_record['domain'] = result.get('domain') + proto_record['email'] = result.get('email') + if result.get('port'): + proto_record['port'] = str(result['port']) proto_record['base_protocol'] = install_base proto_record['instance'] = protocol_instance(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol) @@ -3053,6 +3123,82 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S return JSONResponse({'error': str(e)}, status_code=500) +@app.get('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) +async def api_hysteria_get_settings(request: Request, server_id: int, protocol: str = 'hysteria'): + """Return current Hysteria domain/port for the settings modal.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + if not is_valid_protocol(protocol) or protocol_base(protocol) != 'hysteria': + return JSONResponse({'error': 'Invalid protocol'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + settings = manager.get_settings() + ssh.disconnect() + saved = (server.get('protocols') or {}).get(protocol) or {} + if not settings.get('port') and saved.get('port'): + settings['port'] = int(saved['port']) + return {'status': 'success', **settings} + except Exception as e: + logger.exception("Error reading Hysteria settings") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) +async def api_hysteria_update_settings(request: Request, server_id: int, req: HysteriaSettingsRequest): + """Change Hysteria UDP listen port and recreate the container.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + protocol = req.protocol if is_valid_protocol(req.protocol) and protocol_base(req.protocol) == 'hysteria' else 'hysteria' + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + result = manager.update_settings( + port=req.port, + domain=req.domain, + email=req.email, + renew_ssl=bool(req.renew_ssl), + ) + ssh.disconnect() + if result.get('status') == 'error': + return JSONResponse( + {'error': result.get('message') or 'Failed to update settings', **{k: v for k, v in result.items() if k != 'status'}}, + status_code=400, + ) + if result.get('port') or result.get('domain'): + srv_proto = server.setdefault('protocols', {}).setdefault(protocol, {}) + if result.get('port'): + srv_proto['port'] = str(result['port']) + srv_proto['installed'] = True + srv_proto['base_protocol'] = protocol_base(protocol) + srv_proto['instance'] = protocol_instance(protocol) + srv_proto['display_name'] = protocol_display_name(protocol) + srv_proto['container_name'] = protocol_container_name(protocol) + if result.get('domain'): + srv_proto['domain'] = result['domain'] + # Keep server-level SSL defaults in sync + info = server.setdefault('server_info', {}) + if result.get('domain'): + info['ssl_domain'] = result['domain'] + if result.get('email'): + info['ssl_email'] = result['email'] + save_data(data) + return result + except Exception as e: + logger.exception("Error updating Hysteria settings") + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"]) async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): if not _check_admin(request): @@ -3086,6 +3232,7 @@ CONTAINER_NAMES = { 'awg_legacy': 'amnezia-awg-legacy', 'xray': 'amnezia-xray', 'telemt': 'telemt', + 'hysteria': 'amnezia-hysteria', 'dns': 'amnezia-dns', 'wireguard': 'amnezia-wireguard', 'socks5': 'amnezia-socks5proxy', @@ -3392,13 +3539,39 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe ssh.run_sudo_command(f"docker stop {container}") action = 'stopped' else: - ssh.run_sudo_command(f"docker start {container}") + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + meta = mgr._read_metadata() + port = int(meta.get('port') or mgr.DEFAULT_PORT) + mgr._write_config_from_clients(port) + mgr._start_container(port) + else: + ssh.run_sudo_command(f"docker start {container}") action = 'started' + error = '' + recent_logs = '' + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + diag = mgr.get_container_diagnostics(req.protocol) + error = diag.get('error_summary') or '' + recent_logs = diag.get('recent_logs') or '' + if action == 'started' and not diag.get('running'): + ssh.disconnect() + return JSONResponse({ + 'error': error or 'Hysteria failed to start', + 'action': action, + 'container': container, + 'recent_logs': recent_logs, + }, status_code=400) ssh.disconnect() return { 'status': 'success', 'action': action, 'container': container, + 'error': error, + 'recent_logs': recent_logs, } except Exception as e: logger.exception("Error toggling container") @@ -3420,7 +3593,22 @@ async def api_container_logs(request: Request, server_id: int, req: ContainerLog container = protocol_container_name(req.protocol) ssh = get_ssh(server) ssh.connect() - # Generic docker logs for all protocols + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + logs = mgr.get_logs(req.protocol, tail=req.tail or 200) + diag = mgr.get_container_diagnostics(req.protocol) + ssh.disconnect() + return { + 'status': 'success', + 'protocol': req.protocol, + 'container': container, + 'logs': logs, + 'running': bool(diag.get('running')), + 'error': diag.get('error_summary') or '', + 'exit_code': diag.get('exit_code'), + 'container_status': diag.get('status') or '', + } tail = max(20, min(int(req.tail or 200), 2000)) out, err, _ = ssh.run_sudo_command( f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", @@ -3541,6 +3729,10 @@ async def api_server_config(request: Request, server_id: int, req: ProtocolReque from managers.nginx_manager import NginxManager mgr = NginxManager(ssh, req.protocol) config = mgr._get_server_config(req.protocol) + elif protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + config = mgr.get_server_config(req.protocol) else: mgr = AWGManager(ssh) config = mgr._get_server_config(req.protocol) @@ -3585,6 +3777,10 @@ async def api_server_config_save(request: Request, server_id: int, req: ServerCo from managers.nginx_manager import NginxManager mgr = NginxManager(ssh, req.protocol) mgr.save_server_config(req.protocol, req.config) + elif protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + mgr.save_server_config(req.protocol, req.config) else: mgr = AWGManager(ssh) mgr.save_server_config(req.protocol, req.config) diff --git a/managers/backup_manager.py b/managers/backup_manager.py index 65c1cf9..7511ddc 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -62,6 +62,10 @@ class BackupManager: remote_dir = inst_path('/opt/amnezia/telemt') paths['host'] = [remote_dir] paths['container'] = [remote_dir] + elif base == 'hysteria': + remote_dir = inst_path('/opt/amnezia/hysteria') + paths['host'] = [remote_dir] + paths['container'] = ['/etc/hysteria'] elif base == 'dns': paths['host'] = ['/opt/amnezia/dns'] paths['container'] = ['/opt/amnezia/dns'] diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py index 57ed12d..9b1176e 100644 --- a/managers/hysteria_manager.py +++ b/managers/hysteria_manager.py @@ -1,5 +1,5 @@ -""" -Hysteria 2 protocol manager — teddysun/hysteria Docker image. +""" +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. @@ -32,7 +32,7 @@ def _rand_token(length=16): class HysteriaManager: PROTOCOL = 'hysteria' CONTAINER_NAME = 'amnezia-hysteria' - IMAGE_NAME = 'teddysun/hysteria:latest' + IMAGE_NAME = 'tobyxdd/hysteria:v2' CERTBOT_IMAGE = 'certbot/certbot:latest' BASE_DIR = '/opt/amnezia/hysteria' DEFAULT_PORT = 8998 @@ -449,7 +449,8 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP f"--network host " f"--cap-add=NET_ADMIN " f"-v {_q(self.base_dir)}:/etc/hysteria " - f"{self.IMAGE_NAME}" + 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: @@ -475,8 +476,8 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP 'protocol': self.protocol, } - def update_settings(self, port=None): - """Change listen UDP port and recreate the container.""" + 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 @@ -486,6 +487,32 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP 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) @@ -497,13 +524,17 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP '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': f'Hysteria listening on UDP {port}', + 'message': msg, } def _reload_container(self): @@ -539,7 +570,7 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP f"Point A-record to this server and free TCP port 80. {err or out}" ) - # Copy live certs into paths expected by teddysun image + # Copy live certs into paths expected by the server config copy_script = f""" set -e LIVE={_q(self.letsencrypt_dir)}/live/{_q(domain)} diff --git a/static/css/style.css b/static/css/style.css index 6e65ffc..a548807 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -944,6 +944,11 @@ a:hover { color: #38bdf8; } +.protocol-hysteria .protocol-icon { + background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(99, 102, 241, 0.1)); + color: #38bdf8; +} + .protocol-dns .protocol-icon { background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); color: var(--success); diff --git a/telegram_bot.py b/telegram_bot.py index 87210a3..fdc9969 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -24,7 +24,7 @@ _bot_task: Optional[asyncio.Task] = None _callback_refs = {} _pending_inputs = {} -CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "wireguard"} +CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "hysteria", "wireguard"} SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} @@ -137,6 +137,7 @@ def _protocol_display_name(protocol: str) -> str: "awg_legacy": "AmneziaWG Legacy", "xray": "Xray", "telemt": "Telemt", + "hysteria": "Hysteria 2", "dns": "AmneziaDNS", "wireguard": "WireGuard", "socks5": "SOCKS5", @@ -561,7 +562,7 @@ async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, p server_name = server.get("name") or server.get("host", "Unknown") await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server_name)}\n🔌 Protocol: {_e(proto.upper())}") - is_link_proto = _proto_base(proto) in ("xray", "telemt") + is_link_proto = _proto_base(proto) in ("xray", "telemt", "hysteria") if is_link_proto: await api.send_message(chat_id, f"🔗 Connection link (tap to copy):\n{_e(config)}") else: @@ -918,7 +919,7 @@ async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int, async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable): await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server.get('name') or server.get('host'))}\n🔌 Protocol: {_e(proto.upper())}") - if _proto_base(proto) in ("xray", "telemt"): + if _proto_base(proto) in ("xray", "telemt", "hysteria"): await api.send_message(chat_id, f"🔗 Connection link:\n{_e(config)}") else: await api.send_message(chat_id, f"📄 Configuration:\n
{_e(config)}
") diff --git a/templates/index.html b/templates/index.html index 0f6fff7..0e91e2c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -19,6 +19,8 @@
{{ icon('server') }}
@@ -137,6 +139,16 @@
+
+ + +
{{ _('server_ssl_hint') }}
+
+
+ + +
+ +
+ + +
{{ _('server_ssl_hint') }}
+
+
+ + +
+ + +
+
+
{{ icon('refresh') }}
+
+
+
Hysteria 2
+
+ {{ _('hysteria_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+
@@ -657,6 +681,28 @@
{{ _('nginx_install_hint') }}
+ +
+ + +