forked from test2/Amnezia-Web-Panel-main
Allow changing Hysteria UDP port when 443 is already in use.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user