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