diff --git a/app.py b/app.py index 3b027e2..66511d6 100644 --- a/app.py +++ b/app.py @@ -804,8 +804,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: @@ -845,6 +845,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', @@ -864,6 +865,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', @@ -903,6 +905,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) @@ -964,7 +969,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): @@ -1684,6 +1689,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): @@ -2839,6 +2847,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) @@ -2865,6 +2880,9 @@ 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': + proto_record['domain'] = result.get('domain') + proto_record['email'] = result.get('email') proto_record['base_protocol'] = install_base proto_record['instance'] = protocol_instance(install_protocol) proto_record['display_name'] = protocol_display_name(install_protocol) @@ -2973,6 +2991,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', @@ -3161,6 +3180,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) @@ -3205,6 +3228,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 35305ed..1cde323 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 new file mode 100644 index 0000000..cc1d59b --- /dev/null +++ b/managers/hysteria_manager.py @@ -0,0 +1,410 @@ +""" +Hysteria 2 protocol manager — teddysun/hysteria Docker image. + +Installs Hysteria with Let's Encrypt TLS for an admin-provided domain +(certbot standalone on port 80), then runs the UDP QUIC server. +Clients use userpass auth; share links are hysteria2:// URIs. +""" + +from __future__ import annotations + +import json +import logging +import re +import secrets +import shlex +import string +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 = 'teddysun/hysteria:latest' + 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_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 [] + 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), + } + + # ===================== 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.-]+(?/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 _build_server_yaml(self, port, clients): + lines = [ + f'listen: :{int(port)}', + '', + 'tls:', + ' cert: /etc/hysteria/cert.crt', + ' key: /etc/hysteria/private.key', + '', + 'auth:', + ' type: userpass', + ' userpass:', + ] + if clients: + for c in clients: + user = str(c.get('username') or '').strip() + pwd = str(c.get('password') or '').strip() + if user and pwd: + # YAML double-quote to allow special chars + lines.append(f' "{user}": "{pwd}"') + else: + # Placeholder so Hysteria starts; first real client replaces this + lines.append(f' "__bootstrap__": "{_rand_token(24)}"') + lines.extend([ + '', + 'masquerade:', + ' type: proxy', + ' proxy:', + ' url: https://www.bing.com', + ' rewriteHost: true', + '', + ]) + return '\n'.join(lines) + + def _write_config_from_clients(self, port=None): + meta = self._read_metadata() + port = int(port or meta.get('port') or self.DEFAULT_PORT) + clients = self._read_clients() + self._write_file(self.config_path, self._build_server_yaml(port, clients)) + return port + + def _reload_container(self): + name = self.container_name + if self.check_container_running(self.protocol): + self.ssh.run_sudo_command(f"docker restart {name}", timeout=60) + + 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 teddysun image + 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)} +chmod 600 {_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, username, password, name): + userinfo = f"{quote(username, safe='')}:{quote(password, safe='')}" + fragment = quote(name or username, safe='') + sni = domain or host + conn_host = domain or host + return ( + f"hysteria2://{userinfo}@{conn_host}:{int(port)}/" + f"?sni={quote(sni, safe='')}&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): + self.remove_container(protocol_type) + log.append('Removed previous container') + + self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") + self._write_clients([]) + self._write_metadata({ + 'domain': domain, + 'email': email, + 'port': port, + }) + self._write_config_from_clients(port) + log.append('Prepared config directory') + + 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} + + # Map host dir to /etc/hysteria as required by teddysun image + run_cmd = ( + f"docker run -d --restart always " + f"--name {self.container_name} " + f"--cap-add=NET_ADMIN " + f"-p {port}:{port}/udp " + f"-v {_q(self.base_dir)}:/etc/hysteria " + f"{self.IMAGE_NAME}" + ) + _, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60) + if code != 0: + return {'status': 'error', 'message': f'Failed to start Hysteria: {err}', 'log': log} + + log.append(f'Started {self.container_name} on UDP {port}') + 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: + result.append({ + 'clientId': c.get('id'), + 'client_id': c.get('id'), + 'id': c.get('id'), + 'name': c.get('name') or c.get('username'), + 'email': c.get('username'), + 'userData': {'clientName': c.get('name') or 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}" + password = _rand_token(20) + clients = self._read_clients() + clients.append({ + 'id': client_id, + 'name': name or username, + 'username': username, + 'password': password, + 'enabled': True, + }) + self._write_clients(clients) + self._write_config_from_clients(port) + self._reload_container() + config = self._build_share_uri(host, port, domain, username, password, name or username) + 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') + return self._build_share_uri( + host, port, domain, + client.get('username'), client.get('password'), + client.get('name') or client.get('username'), + ) + + 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._write_config_from_clients() + self._reload_container() + return True + + def toggle_client(self, protocol_type, client_id, enable=True): + # Soft-disable: remove from yaml but keep in table with enabled=false + 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) + # Rebuild yaml with only enabled clients + active = [c for c in clients if c.get('enabled', True)] + meta = self._read_metadata() + port = int(meta.get('port') or self.DEFAULT_PORT) + self._write_file(self.config_path, self._build_server_yaml(port, active)) + self._reload_container() + return True diff --git a/static/css/style.css b/static/css/style.css index 850cde9..70fede5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -823,6 +823,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 48fe59b..cb4d9c0 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", @@ -549,7 +550,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: @@ -906,7 +907,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/invites.html b/templates/invites.html index 56367b7..405c0c4 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -128,7 +128,7 @@ {% 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'] %} + {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt','hysteria'] %} {% endif %} {% endfor %} diff --git a/templates/my_connections.html b/templates/my_connections.html index 8f6faeb..706e53e 100644 --- a/templates/my_connections.html +++ b/templates/my_connections.html @@ -117,10 +117,10 @@ document.getElementById('configText').textContent = result.config; document.getElementById('vpnLinkText').textContent = currentVpnLink; - // Telemt (MTProxy) doesn't have a "VPN Link" (vpn://) format in Amnezia + // Telemt / Hysteria use share links, not vpn:// Amnezia format const vpnTab = document.querySelectorAll('.config-tab')[1]; const vpnPanel = document.getElementById('panel-vpn'); - if (proto === 'telemt') { + if (proto === 'telemt' || String(proto || '').split('__')[0] === 'hysteria') { vpnTab.style.display = 'none'; } else { vpnTab.style.display = ''; diff --git a/templates/server.html b/templates/server.html index 4a06506..6b37fd7 100644 --- a/templates/server.html +++ b/templates/server.html @@ -294,6 +294,30 @@ + +
+
+
🌀
+
+
+
Hysteria 2
+
+ {{ _('hysteria_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+
@@ -655,6 +679,20 @@
{{ _('nginx_install_hint') }}
+ +
- + ${proto === 'telemt' ? `` : ''} @@ -2115,8 +2178,8 @@ document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`; document.getElementById('configText').textContent = result.config; document.getElementById('vpnLinkText').textContent = currentVpnLink; - document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); - document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); + document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); + document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); switchConfigTab('conf'); openModal('configModal'); generateQR(result.config); @@ -2194,9 +2257,9 @@ const proto = document.getElementById('connProtoSelect').value; // Restore tabs visibility first - document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : ''); + document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); document.getElementById('panel-qr').style.display = ''; - document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : ''); + document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' ? 'none' : ''); document.querySelectorAll('.config-tab')[2].style.display = ''; // Client was created via native Amnezia app — private key is not stored server-side diff --git a/templates/settings.html b/templates/settings.html index 7cd16fd..23d74f9 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -115,7 +115,7 @@ {% 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'] %} + {% if info.installed and key.split('__')[0] in ['awg','awg2','awg_legacy','xray','wireguard','telemt','hysteria'] %}