Allow changing Hysteria UDP port when 443 is already in use.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 02:35:55 +03:00
co-authored by Cursor
parent 7323f08c37
commit d480d149b6
8 changed files with 277 additions and 11 deletions
+90 -5
View File
@@ -14,6 +14,7 @@ import re
import secrets
import shlex
import string
import time
from urllib.parse import quote
logger = logging.getLogger(__name__)
@@ -34,7 +35,7 @@ class HysteriaManager:
IMAGE_NAME = 'teddysun/hysteria:latest'
CERTBOT_IMAGE = 'certbot/certbot:latest'
BASE_DIR = '/opt/amnezia/hysteria'
DEFAULT_PORT = 443
DEFAULT_PORT = 8998
def __init__(self, ssh, protocol='hysteria'):
self.ssh = ssh
@@ -158,15 +159,30 @@ class HysteriaManager:
# Pull last meaningful log line
last = ''
for line in reversed((diag['recent_logs'] or '').splitlines()):
line = line.strip()
if line:
line = self._strip_ansi(line).strip()
if not line:
continue
# Prefer the FATAL / bind error line
if 'address already in use' in line.lower() or 'FATAL' in line or 'failed to load' in line.lower():
last = line
break
if not last:
last = line
if last:
# strip docker timestamp prefix if present
if ' ' in last and last[0].isdigit():
if ' ' in last and (last[0].isdigit() or last[0] == '2'):
last = last.split(' ', 1)[-1]
diag['error_summary'] = f"exit {diag['exit_code']}: {last[:180]}"
last = self._strip_ansi(last)
if 'address already in use' in last.lower():
m = re.search(r'listen udp :(\d+)', last, re.I) or re.search(r':(\d+):\s*bind', last)
p = m.group(1) if m else '?'
diag['error_summary'] = (
f'UDP port {p} already in use — open Settings and choose another port'
)
else:
# keep message short for badge
short = re.sub(r'\s+', ' ', last)[:160]
diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
else:
diag['error_summary'] = f"exited with code {diag['exit_code']}"
elif diag['status'] and diag['status'] != 'running':
@@ -375,6 +391,19 @@ sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
"""
self.ssh.run_sudo_command(f"sh -c {_q(script)}", timeout=30)
def _strip_ansi(self, text):
return re.sub(r'\x1b\[[0-9;]*m', '', text or '')
def _udp_port_busy(self, port):
"""Return (busy: bool, detail: str) for host UDP port (after our container is removed)."""
port = int(port)
out, _, _ = self.ssh.run_sudo_command(
f"ss -ulnp 'sport = :{port}' 2>/dev/null || "
f"ss -uln | grep -E ':{port}([[:space:]]|$)' || true"
)
detail = (out or '').strip()
return bool(detail), detail[:240]
def _open_udp_port(self, port):
port = int(port)
script = f"""
@@ -394,12 +423,20 @@ 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)
if port == 80:
raise RuntimeError('Port 80 is reserved for Let\'s Encrypt validation')
self._tune_host_network()
self._open_udp_port(port)
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")
busy, detail = self._udp_port_busy(port)
if busy:
raise RuntimeError(
f'UDP port {port} is already in use — choose another port in Hysteria settings. '
f'({self._strip_ansi(detail)})'
)
run_cmd = (
f"docker run -d --restart always "
f"--name {name} "
@@ -411,8 +448,56 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP
_, err, code = self.ssh.run_sudo_command(run_cmd, timeout=60)
if code != 0:
raise RuntimeError(f'Failed to start Hysteria: {err}')
# Confirm it stayed up (catch immediate bind failures)
time.sleep(1.2)
if not self.check_container_running(self.protocol):
diag = self.get_container_diagnostics(self.protocol)
raise RuntimeError(
diag.get('error_summary')
or 'Hysteria container exited immediately — check logs / port'
)
return True
def get_settings(self):
meta = self._ensure_secrets(self._read_metadata())
return {
'port': int(meta.get('port') or self.DEFAULT_PORT),
'domain': meta.get('domain') or '',
'email': meta.get('email') or '',
'protocol': self.protocol,
}
def update_settings(self, port=None):
"""Change listen UDP port and recreate the container."""
meta = self._ensure_secrets(self._read_metadata())
if port is None:
port = meta.get('port') or self.DEFAULT_PORT
port = int(port)
if port < 1 or port > 65535:
return {'status': 'error', 'message': 'Invalid port'}
if port == 80:
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
meta['port'] = port
self._write_metadata(meta)
self._write_config_from_clients(port)
try:
self._start_container(port)
except Exception as e:
return {
'status': 'error',
'message': str(e),
'port': str(port),
'domain': meta.get('domain'),
}
return {
'status': 'success',
'port': str(port),
'domain': meta.get('domain'),
'email': meta.get('email'),
'message': f'Hysteria listening on UDP {port}',
}
def _reload_container(self):
meta = self._read_metadata()
port = int(meta.get('port') or self.DEFAULT_PORT)