From 9b495b08b53ae011d965236e4f23b38a7031308b Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 01:58:54 +0300 Subject: [PATCH] Fix Hysteria QUIC EOF by using host network and larger UDP buffers. Co-authored-by: Cursor --- managers/hysteria_manager.py | 148 ++++++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 29 deletions(-) diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py index cc1d59b..ab554d0 100644 --- a/managers/hysteria_manager.py +++ b/managers/hysteria_manager.py @@ -97,6 +97,23 @@ class HysteriaManager: running = self.check_container_running(protocol_type) 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) + if exists: + try: + name = self._container_name(protocol_type) + mode, _, _ = self.ssh.run_sudo_command( + f"docker inspect -f '{{{{.HostConfig.NetworkMode}}}}' {name} 2>/dev/null" + ) + mode = (mode or '').strip() + if mode and mode != 'host': + 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) + except Exception as e: + logger.warning('Hysteria host-network migrate failed: %s', e) + return { 'container_exists': exists, 'container_running': running, @@ -174,15 +191,33 @@ class HysteriaManager: ] if clients: for c in clients: - user = str(c.get('username') or '').strip() + 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 to allow special chars + # 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([ + '', + # Larger windows + disable PMTUD: avoids common QUIC EOF behind VPS/Docker NAT + 'quic:', + ' initStreamReceiveWindow: 8388608', + ' maxStreamReceiveWindow: 8388608', + ' initConnReceiveWindow: 20971520', + ' maxConnReceiveWindow: 20971520', + ' maxIdleTimeout: 60s', + ' maxIncomingStreams: 1024', + ' disablePathMTUDiscovery: true', + '', + 'ignoreClientBandwidth: true', + '', + 'bandwidth:', + ' up: 1 gbps', + ' down: 1 gbps', '', 'masquerade:', ' type: proxy', @@ -190,6 +225,10 @@ class HysteriaManager: ' url: https://www.bing.com', ' rewriteHost: true', '', + 'outbounds:', + ' - name: direct', + ' type: direct', + '', ]) return '\n'.join(lines) @@ -200,10 +239,72 @@ class HysteriaManager: self._write_file(self.config_path, self._build_server_yaml(port, clients)) return port - def _reload_container(self): + def _tune_host_udp_buffers(self): + """Raise kernel UDP buffers — too-small buffers cause quic-go EOF on clients.""" + script = r""" +set +e +mkdir -p /etc/sysctl.d +printf '%s\n' \ + 'net.core.rmem_max=16777216' \ + 'net.core.wmem_max=16777216' \ + 'net.core.rmem_default=16777216' \ + 'net.core.wmem_default=16777216' \ + > /etc/sysctl.d/99-amnezia-hysteria.conf +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 +""" + self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) + + def _open_udp_port(self, port): + port = int(port) + script = f""" +set +e +if command -v ufw >/dev/null 2>&1; then + ufw allow {port}/udp >/dev/null 2>&1 || true +fi +if command -v firewall-cmd >/dev/null 2>&1; then + firewall-cmd --permanent --add-port={port}/udp >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true +fi +iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || true +""" + self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30) + + def _start_container(self, port): + """Run with --network host so QUIC/UDP is not broken by Docker NAT (EOF).""" name = self.container_name - if self.check_container_running(self.protocol): - self.ssh.run_sudo_command(f"docker restart {name}", timeout=60) + port = int(port) + self._tune_host_udp_buffers() + 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" + ) + self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true") + run_cmd = ( + f"docker run -d --restart always " + f"--name {name} " + f"--network host " + f"--cap-add=NET_ADMIN " + 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: + raise RuntimeError(f'Failed to start Hysteria: {err}') + 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: + self._start_container(port) + except Exception as e: + logger.warning('Hysteria container reload failed: %s', e) def _issue_certificate(self, domain, email): """Issue Let's Encrypt cert via certbot standalone (needs free TCP 80).""" @@ -238,16 +339,16 @@ 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)} +chmod 644 {_q(self.cert_path)} {_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='') + user = str(username or '').strip().lower() + userinfo = f"{quote(user, safe='')}:{quote(password, safe='')}" + fragment = quote(name or user, safe='') sni = domain or host conn_host = domain or host return ( @@ -273,7 +374,8 @@ chmod 600 {_q(self.key_path)} log.append(f'Pulled {self.IMAGE_NAME}') if self.check_protocol_installed(protocol_type): - self.remove_container(protocol_type) + name = self._container_name(protocol_type) + self.ssh.run_sudo_command(f"docker rm -fv {name} 2>/dev/null || true") log.append('Removed previous container') self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}") @@ -292,20 +394,12 @@ chmod 600 {_q(self.key_path)} 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} + try: + self._start_container(port) + except Exception as e: + return {'status': 'error', 'message': str(e), 'log': log} - log.append(f'Started {self.container_name} on UDP {port}') + log.append(f'Started {self.container_name} on UDP {port} (host network)') return { 'status': 'success', 'message': 'Hysteria installed', @@ -352,7 +446,7 @@ chmod 600 {_q(self.key_path)} 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}" + username = f"u_{client_id}" # lowercase — hysteria userpass keys are case-folded password = _rand_token(20) clients = self._read_clients() clients.append({ @@ -401,10 +495,6 @@ chmod 600 {_q(self.key_path)} 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._write_config_from_clients() self._reload_container() return True