Template
Fix Hysteria QUIC EOF by using host network and larger UDP buffers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+119
-29
@@ -97,6 +97,23 @@ class HysteriaManager:
|
|||||||
running = self.check_container_running(protocol_type)
|
running = self.check_container_running(protocol_type)
|
||||||
meta = self._read_metadata() if exists else {}
|
meta = self._read_metadata() if exists else {}
|
||||||
clients = self._read_clients() 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 {
|
return {
|
||||||
'container_exists': exists,
|
'container_exists': exists,
|
||||||
'container_running': running,
|
'container_running': running,
|
||||||
@@ -174,15 +191,33 @@ class HysteriaManager:
|
|||||||
]
|
]
|
||||||
if clients:
|
if clients:
|
||||||
for c in 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()
|
pwd = str(c.get('password') or '').strip()
|
||||||
if user and pwd:
|
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}"')
|
lines.append(f' "{user}": "{pwd}"')
|
||||||
else:
|
else:
|
||||||
# Placeholder so Hysteria starts; first real client replaces this
|
# Placeholder so Hysteria starts; first real client replaces this
|
||||||
lines.append(f' "__bootstrap__": "{_rand_token(24)}"')
|
lines.append(f' "__bootstrap__": "{_rand_token(24)}"')
|
||||||
lines.extend([
|
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:',
|
'masquerade:',
|
||||||
' type: proxy',
|
' type: proxy',
|
||||||
@@ -190,6 +225,10 @@ class HysteriaManager:
|
|||||||
' url: https://www.bing.com',
|
' url: https://www.bing.com',
|
||||||
' rewriteHost: true',
|
' rewriteHost: true',
|
||||||
'',
|
'',
|
||||||
|
'outbounds:',
|
||||||
|
' - name: direct',
|
||||||
|
' type: direct',
|
||||||
|
'',
|
||||||
])
|
])
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
@@ -200,10 +239,72 @@ class HysteriaManager:
|
|||||||
self._write_file(self.config_path, self._build_server_yaml(port, clients))
|
self._write_file(self.config_path, self._build_server_yaml(port, clients))
|
||||||
return port
|
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
|
name = self.container_name
|
||||||
if self.check_container_running(self.protocol):
|
port = int(port)
|
||||||
self.ssh.run_sudo_command(f"docker restart {name}", timeout=60)
|
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):
|
def _issue_certificate(self, domain, email):
|
||||||
"""Issue Let's Encrypt cert via certbot standalone (needs free TCP 80)."""
|
"""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"
|
test -s "$LIVE/privkey.pem"
|
||||||
cp -f "$LIVE/fullchain.pem" {_q(self.cert_path)}
|
cp -f "$LIVE/fullchain.pem" {_q(self.cert_path)}
|
||||||
cp -f "$LIVE/privkey.pem" {_q(self.key_path)}
|
cp -f "$LIVE/privkey.pem" {_q(self.key_path)}
|
||||||
chmod 644 {_q(self.cert_path)}
|
chmod 644 {_q(self.cert_path)} {_q(self.key_path)}
|
||||||
chmod 600 {_q(self.key_path)}
|
|
||||||
"""
|
"""
|
||||||
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
|
out, err, code = self.ssh.run_sudo_command(f"sh -c {_q(copy_script)}", timeout=30)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(f'Failed to install certificate files: {err or out}')
|
raise RuntimeError(f'Failed to install certificate files: {err or out}')
|
||||||
|
|
||||||
def _build_share_uri(self, host, port, domain, username, password, name):
|
def _build_share_uri(self, host, port, domain, username, password, name):
|
||||||
userinfo = f"{quote(username, safe='')}:{quote(password, safe='')}"
|
user = str(username or '').strip().lower()
|
||||||
fragment = quote(name or username, safe='')
|
userinfo = f"{quote(user, safe='')}:{quote(password, safe='')}"
|
||||||
|
fragment = quote(name or user, safe='')
|
||||||
sni = domain or host
|
sni = domain or host
|
||||||
conn_host = domain or host
|
conn_host = domain or host
|
||||||
return (
|
return (
|
||||||
@@ -273,7 +374,8 @@ chmod 600 {_q(self.key_path)}
|
|||||||
log.append(f'Pulled {self.IMAGE_NAME}')
|
log.append(f'Pulled {self.IMAGE_NAME}')
|
||||||
|
|
||||||
if self.check_protocol_installed(protocol_type):
|
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')
|
log.append('Removed previous container')
|
||||||
|
|
||||||
self.ssh.run_sudo_command(f"mkdir -p {_q(self.base_dir)}")
|
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:
|
except Exception as e:
|
||||||
return {'status': 'error', 'message': str(e), 'log': log}
|
return {'status': 'error', 'message': str(e), 'log': log}
|
||||||
|
|
||||||
# Map host dir to /etc/hysteria as required by teddysun image
|
try:
|
||||||
run_cmd = (
|
self._start_container(port)
|
||||||
f"docker run -d --restart always "
|
except Exception as e:
|
||||||
f"--name {self.container_name} "
|
return {'status': 'error', 'message': str(e), 'log': log}
|
||||||
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}')
|
log.append(f'Started {self.container_name} on UDP {port} (host network)')
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Hysteria installed',
|
'message': 'Hysteria installed',
|
||||||
@@ -352,7 +446,7 @@ chmod 600 {_q(self.key_path)}
|
|||||||
domain = meta.get('domain') or host
|
domain = meta.get('domain') or host
|
||||||
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
port = int(port or meta.get('port') or self.DEFAULT_PORT)
|
||||||
client_id = secrets.token_hex(8)
|
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)
|
password = _rand_token(20)
|
||||||
clients = self._read_clients()
|
clients = self._read_clients()
|
||||||
clients.append({
|
clients.append({
|
||||||
@@ -401,10 +495,6 @@ chmod 600 {_q(self.key_path)}
|
|||||||
if not found:
|
if not found:
|
||||||
raise RuntimeError('Client not found')
|
raise RuntimeError('Client not found')
|
||||||
self._write_clients(clients)
|
self._write_clients(clients)
|
||||||
# Rebuild yaml with only enabled clients
|
self._write_config_from_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()
|
self._reload_container()
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user