From fda60eaa9a8863b587af46d1ed3f4abf6f886c6e Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 02:18:34 +0300 Subject: [PATCH] Improve Hysteria for Happ: BBR, password auth, salamander, UDP 443. Co-authored-by: Cursor --- managers/hysteria_manager.py | 148 ++++++++++++++++++++--------------- templates/server.html | 2 +- translations/en.json | 4 +- translations/fa.json | 4 +- translations/fr.json | 4 +- translations/ru.json | 4 +- translations/zh.json | 4 +- 7 files changed, 97 insertions(+), 73 deletions(-) diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py index ab554d0..2554524 100644 --- a/managers/hysteria_manager.py +++ b/managers/hysteria_manager.py @@ -2,8 +2,8 @@ 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. +(certbot standalone on port 80), host networking, BBR, and salamander obfs. +Clients use password auth (Happ/INCY-compatible); share links are hy2:// URIs. """ from __future__ import annotations @@ -34,7 +34,7 @@ class HysteriaManager: IMAGE_NAME = 'teddysun/hysteria:latest' CERTBOT_IMAGE = 'certbot/certbot:latest' BASE_DIR = '/opt/amnezia/hysteria' - DEFAULT_PORT = 8998 + DEFAULT_PORT = 443 def __init__(self, ssh, protocol='hysteria'): self.ssh = ssh @@ -98,7 +98,7 @@ class HysteriaManager: meta = self._read_metadata() if exists else {} clients = self._read_clients() if exists else [] - # Auto-migrate older bridge/-p installs that break QUIC (client EOF while ping works) + # Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs) if exists: try: name = self._container_name(protocol_type) @@ -106,13 +106,20 @@ class HysteriaManager: f"docker inspect -f '{{{{.HostConfig.NetworkMode}}}}' {name} 2>/dev/null" ) mode = (mode or '').strip() - if mode and mode != 'host': + cfg = self._read_file(self.config_path) + needs_cfg = ( + 'type: userpass' in cfg + or 'type: salamander' not in cfg + or 'type: password' not in cfg + ) + if (mode and mode != 'host') or needs_cfg: port = int(meta.get('port') or self.DEFAULT_PORT) self._write_config_from_clients(port) self._start_container(port) running = self.check_container_running(protocol_type) + meta = self._read_metadata() except Exception as e: - logger.warning('Hysteria host-network migrate failed: %s', e) + logger.warning('Hysteria migrate failed: %s', e) return { 'container_exists': exists, @@ -177,7 +184,24 @@ class HysteriaManager: 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): + def _ensure_secrets(self, meta): + """Password auth (Happ/INCY-friendly) + salamander obfs secrets.""" + changed = False + if not meta.get('password'): + meta['password'] = _rand_token(24) + changed = True + if not meta.get('obfs_password'): + meta['obfs_password'] = _rand_token(16) + changed = True + if changed: + self._write_metadata(meta) + return meta + + def _build_server_yaml(self, port, clients, meta=None): + meta = self._ensure_secrets(meta if meta is not None else self._read_metadata()) + password = meta['password'] + obfs_password = meta['obfs_password'] + # password auth — Happ / sing-box / INCY expect a single auth string, not userpass lines = [ f'listen: :{int(port)}', '', @@ -186,24 +210,14 @@ class HysteriaManager: ' key: /etc/hysteria/private.key', '', 'auth:', - ' type: userpass', - ' userpass:', - ] - if clients: - for c in clients: - if c.get('enabled', True) is False: - continue - user = str(c.get('username') or '').strip().lower() - pwd = str(c.get('password') or '').strip() - if user and pwd: - # YAML double-quote; usernames lowercased (viper maps keys to lower) - lines.append(f' "{user}": "{pwd}"') - else: - # Placeholder so Hysteria starts; first real client replaces this - lines.append(f' "__bootstrap__": "{_rand_token(24)}"') - lines.extend([ + ' type: password', + f' password: "{password}"', + '', + 'obfs:', + ' type: salamander', + ' salamander:', + f' password: "{obfs_password}"', '', - # Larger windows + disable PMTUD: avoids common QUIC EOF behind VPS/Docker NAT 'quic:', ' initStreamReceiveWindow: 8388608', ' maxStreamReceiveWindow: 8388608', @@ -229,18 +243,18 @@ class HysteriaManager: ' - name: direct', ' type: direct', '', - ]) + ] return '\n'.join(lines) def _write_config_from_clients(self, port=None): - meta = self._read_metadata() + meta = self._ensure_secrets(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)) + # clients list is panel-side only (shared password); still rewrite yaml for port/obfs/secrets + self._write_file(self.config_path, self._build_server_yaml(port, self._read_clients(), meta)) return port - def _tune_host_udp_buffers(self): - """Raise kernel UDP buffers — too-small buffers cause quic-go EOF on clients.""" + def _tune_host_network(self): + """UDP buffers + BBR — reduces quic-go EOF and improves throughput.""" script = r""" set +e mkdir -p /etc/sysctl.d @@ -249,12 +263,19 @@ printf '%s\n' \ 'net.core.wmem_max=16777216' \ 'net.core.rmem_default=16777216' \ 'net.core.wmem_default=16777216' \ + 'net.core.default_qdisc=fq' \ + 'net.ipv4.tcp_congestion_control=bbr' \ + 'net.ipv4.ip_forward=1' \ > /etc/sysctl.d/99-amnezia-hysteria.conf +modprobe tcp_bbr >/dev/null 2>&1 || true sysctl -p /etc/sysctl.d/99-amnezia-hysteria.conf >/dev/null 2>&1 || true sysctl -w net.core.rmem_max=16777216 >/dev/null 2>&1 || true sysctl -w net.core.wmem_max=16777216 >/dev/null 2>&1 || true sysctl -w net.core.rmem_default=16777216 >/dev/null 2>&1 || true sysctl -w net.core.wmem_default=16777216 >/dev/null 2>&1 || true +sysctl -w net.core.default_qdisc=fq >/dev/null 2>&1 || true +sysctl -w net.ipv4.tcp_congestion_control=bbr >/dev/null 2>&1 || true +sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true """ self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) @@ -277,9 +298,8 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP """Run with --network host so QUIC/UDP is not broken by Docker NAT (EOF).""" name = self.container_name port = int(port) - self._tune_host_udp_buffers() + self._tune_host_network() self._open_udp_port(port) - # Cert must be readable inside the container (non-root entrypoint possible) self.ssh.run_sudo_command( f"chmod 644 {_q(self.cert_path)} {_q(self.key_path)} 2>/dev/null || true" ) @@ -298,7 +318,6 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP return True def _reload_container(self): - # Recreate (not restart) so older bridge/-p installs are migrated to host network meta = self._read_metadata() port = int(meta.get('port') or self.DEFAULT_PORT) try: @@ -345,15 +364,21 @@ chmod 644 {_q(self.cert_path)} {_q(self.key_path)} 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): - user = str(username or '').strip().lower() - userinfo = f"{quote(user, safe='')}:{quote(password, safe='')}" - fragment = quote(name or user, safe='') + def _build_share_uri(self, host, port, domain, name, meta=None): + """Happ/INCY-compatible hy2 link: password auth + salamander + IP host + SNI.""" + meta = self._ensure_secrets(meta if meta is not None else self._read_metadata()) + password = meta['password'] + obfs_password = meta['obfs_password'] sni = domain or host - conn_host = domain or host + # Prefer raw IP in URI — domain may be CDN/DNS filtered for UDP while ICMP still works + conn_host = host or domain + fragment = quote(name or 'hysteria', safe='') return ( - f"hysteria2://{userinfo}@{conn_host}:{int(port)}/" - f"?sni={quote(sni, safe='')}&insecure=0#{fragment}" + f"hy2://{quote(password, safe='')}@{conn_host}:{int(port)}/" + f"?sni={quote(sni, safe='')}" + f"&obfs=salamander" + f"&obfs-password={quote(obfs_password, safe='')}" + f"&insecure=0#{fragment}" ) # ===================== INSTALL / REMOVE ===================== @@ -380,13 +405,16 @@ chmod 644 {_q(self.cert_path)} {_q(self.key_path)} self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") self._write_clients([]) - self._write_metadata({ + meta = { 'domain': domain, 'email': email, 'port': port, - }) + 'password': _rand_token(24), + 'obfs_password': _rand_token(16), + } + self._write_metadata(meta) self._write_config_from_clients(port) - log.append('Prepared config directory') + log.append('Prepared config directory (password auth + salamander + BBR)') try: self._issue_certificate(domain, email) @@ -399,7 +427,7 @@ chmod 644 {_q(self.cert_path)} {_q(self.key_path)} except Exception as e: return {'status': 'error', 'message': str(e), 'log': log} - log.append(f'Started {self.container_name} on UDP {port} (host network)') + log.append(f'Started {self.container_name} on UDP {port} (host network, BBR)') return { 'status': 'success', 'message': 'Hysteria installed', @@ -431,60 +459,58 @@ chmod 644 {_q(self.cert_path)} {_q(self.key_path)} clients = self._read_clients() result = [] for c in clients: + cname = c.get('name') or c.get('id') result.append({ 'clientId': c.get('id'), 'client_id': c.get('id'), 'id': c.get('id'), - 'name': c.get('name') or c.get('username'), - 'email': c.get('username'), - 'userData': {'clientName': c.get('name') or c.get('username')}, + 'name': cname, + 'email': cname, + 'enabled': c.get('enabled', True), + 'userData': {'clientName': cname, 'enabled': c.get('enabled', True)}, }) return result def add_client(self, protocol_type, name, host, port=None): - meta = self._read_metadata() + meta = self._ensure_secrets(self._read_metadata()) domain = meta.get('domain') or host port = int(port or meta.get('port') or self.DEFAULT_PORT) client_id = secrets.token_hex(8) - username = f"u_{client_id}" # lowercase — hysteria userpass keys are case-folded - password = _rand_token(20) clients = self._read_clients() clients.append({ 'id': client_id, - 'name': name or username, - 'username': username, - 'password': password, + 'name': name or f'client-{client_id[:6]}', 'enabled': True, }) self._write_clients(clients) + # Shared password auth — refresh yaml only if secrets/port changed self._write_config_from_clients(port) self._reload_container() - config = self._build_share_uri(host, port, domain, username, password, name or username) + config = self._build_share_uri(host, port, domain, name or client_id, meta) return {'client_id': client_id, 'config': config} def get_client_config(self, protocol_type, client_id, host, port=None): - meta = self._read_metadata() + meta = self._ensure_secrets(self._read_metadata()) domain = meta.get('domain') or host port = int(port or meta.get('port') or self.DEFAULT_PORT) clients = self._read_clients() client = next((c for c in clients if c.get('id') == client_id), None) if not client: raise RuntimeError('Client not found') + if client.get('enabled', True) is False: + raise RuntimeError('Client is disabled') return self._build_share_uri( host, port, domain, - client.get('username'), client.get('password'), - client.get('name') or client.get('username'), + client.get('name') or client_id, + meta, ) def remove_client(self, protocol_type, client_id): clients = [c for c in self._read_clients() if c.get('id') != client_id] self._write_clients(clients) - 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: @@ -495,6 +521,4 @@ chmod 644 {_q(self.cert_path)} {_q(self.key_path)} if not found: raise RuntimeError('Client not found') self._write_clients(clients) - self._write_config_from_clients() - self._reload_container() return True diff --git a/templates/server.html b/templates/server.html index 6b37fd7..6503a11 100644 --- a/templates/server.html +++ b/templates/server.html @@ -1801,7 +1801,7 @@ updateNginxDnsHint(); } else if (base === 'hysteria') { portLabel.textContent = _('port') + ' (UDP)'; - portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998'; + portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443'; portInput.disabled = false; portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('hysteria_port_hint'); hysteriaOpts.style.display = 'block'; diff --git a/translations/en.json b/translations/en.json index 659e446..9b10e3f 100644 --- a/translations/en.json +++ b/translations/en.json @@ -359,8 +359,8 @@ "hysteria_desc": "Hysteria 2 (QUIC/UDP) via teddysun/hysteria with Let's Encrypt TLS. Admin sets the domain at install time.", "hysteria_domain": "Domain", "hysteria_email": "Let's Encrypt email", - "hysteria_port_hint": "UDP port for Hysteria (default 8998). Port 80/TCP must be free for certificate issuance.", - "hysteria_install_hint": "Point the domain A-record to this server before install. Port 80/TCP is used temporarily for Let's Encrypt HTTP-01 validation.", + "hysteria_port_hint": "UDP port for Hysteria (default 443 — better for DPI). Port 80/TCP must be free for certificate issuance.", + "hysteria_install_hint": "Point the domain A-record to this server before install. Port 80/TCP is used temporarily for Let's Encrypt HTTP-01 validation. BBR and salamander obfuscation are enabled automatically.", "hysteria_dns_hint": "Create this DNS record before installation:", "tls_emulation": "TLS Emulation", "tls_domain": "Masking Domain", diff --git a/translations/fa.json b/translations/fa.json index e883c93..7d6af92 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -364,8 +364,8 @@ "hysteria_desc": "Hysteria 2 (QUIC/UDP) با تصویر teddysun/hysteria و TLS از Let's Encrypt. دامنه را ادمین هنگام نصب مشخص می‌کند.", "hysteria_domain": "دامنه", "hysteria_email": "ایمیل Let's Encrypt", - "hysteria_port_hint": "پورت UDP برای Hysteria (پیش‌فرض 8998). پورت 80/TCP باید برای صدور گواهی آزاد باشد.", - "hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. پورت 80/TCP موقتاً برای اعتبارسنجی HTTP-01 استفاده می‌شود.", + "hysteria_port_hint": "پورت UDP برای Hysteria (پیش‌فرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.", + "hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander به‌صورت خودکار فعال می‌شوند.", "hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:", "dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", diff --git a/translations/fr.json b/translations/fr.json index 2c536cd..5dd0622 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -364,8 +364,8 @@ "hysteria_desc": "Hysteria 2 (QUIC/UDP) via teddysun/hysteria avec TLS Let's Encrypt. Le domaine est défini par l'admin à l'installation.", "hysteria_domain": "Domaine", "hysteria_email": "Email Let's Encrypt", - "hysteria_port_hint": "Port UDP pour Hysteria (8998 par défaut). Le port 80/TCP doit être libre pour le certificat.", - "hysteria_install_hint": "L'enregistrement A du domaine doit pointer vers ce serveur. Le port 80/TCP est utilisé temporairement pour la validation HTTP-01 Let's Encrypt.", + "hysteria_port_hint": "Port UDP pour Hysteria (443 par défaut). Le port 80/TCP doit être libre pour le certificat.", + "hysteria_install_hint": "L'enregistrement A du domaine doit pointer vers ce serveur. BBR et obfuscation salamander sont activés automatiquement.", "hysteria_dns_hint": "Créez cet enregistrement DNS avant l'installation :", "dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", diff --git a/translations/ru.json b/translations/ru.json index 6768b86..e32164f 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -359,8 +359,8 @@ "hysteria_desc": "Hysteria 2 (QUIC/UDP) на образе teddysun/hysteria с TLS от Let's Encrypt. Домен указывает админ при установке.", "hysteria_domain": "Домен", "hysteria_email": "Email для Let's Encrypt", - "hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 8998). Порт 80/TCP должен быть свободен для выпуска сертификата.", - "hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt.", + "hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.", + "hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.", "hysteria_dns_hint": "Перед установкой создайте DNS-запись:", "tls_emulation": "TLS-эмуляция", "tls_domain": "Домен маскировки", diff --git a/translations/zh.json b/translations/zh.json index 396dd9c..8e46b6f 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -364,8 +364,8 @@ "hysteria_desc": "Hysteria 2(QUIC/UDP,teddysun/hysteria),支持 Let's Encrypt TLS。安装时由管理员指定域名。", "hysteria_domain": "域名", "hysteria_email": "Let's Encrypt 邮箱", - "hysteria_port_hint": "Hysteria UDP 端口(默认 8998)。签发证书时需要 80/TCP 空闲。", - "hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。80/TCP 会临时用于 Let's Encrypt HTTP-01 验证。", + "hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。", + "hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。", "hysteria_dns_hint": "安装前请创建此 DNS 记录:", "dns_desc": "防止广告和保护隐私的个人 DNS 服务器。", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",