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 @@
+
+