diff --git a/app.py b/app.py index f7b160b..e422ac8 100644 --- a/app.py +++ b/app.py @@ -1701,6 +1701,11 @@ class Socks5SettingsRequest(BaseModel): password: Optional[str] = None +class HysteriaSettingsRequest(BaseModel): + protocol: str = 'hysteria' + port: Optional[int] = None + + class ProtocolRequest(BaseModel): protocol: str = 'awg' @@ -2963,6 +2968,70 @@ async def api_socks5_update_credentials(request: Request, server_id: int, req: S return JSONResponse({'error': str(e)}, status_code=500) +@app.get('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) +async def api_hysteria_get_settings(request: Request, server_id: int, protocol: str = 'hysteria'): + """Return current Hysteria domain/port for the settings modal.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + if not is_valid_protocol(protocol) or protocol_base(protocol) != 'hysteria': + return JSONResponse({'error': 'Invalid protocol'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + settings = manager.get_settings() + ssh.disconnect() + saved = (server.get('protocols') or {}).get(protocol) or {} + if not settings.get('port') and saved.get('port'): + settings['port'] = int(saved['port']) + return {'status': 'success', **settings} + except Exception as e: + logger.exception("Error reading Hysteria settings") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/hysteria/settings', tags=["Protocols"]) +async def api_hysteria_update_settings(request: Request, server_id: int, req: HysteriaSettingsRequest): + """Change Hysteria UDP listen port and recreate the container.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + protocol = req.protocol if is_valid_protocol(req.protocol) and protocol_base(req.protocol) == 'hysteria' else 'hysteria' + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + result = manager.update_settings(port=req.port) + ssh.disconnect() + if result.get('status') == 'error': + return JSONResponse( + {'error': result.get('message') or 'Failed to update settings', **{k: v for k, v in result.items() if k != 'status'}}, + status_code=400, + ) + if result.get('port'): + srv_proto = server.setdefault('protocols', {}).setdefault(protocol, {}) + srv_proto['port'] = str(result['port']) + srv_proto['installed'] = True + srv_proto['base_protocol'] = protocol_base(protocol) + srv_proto['instance'] = protocol_instance(protocol) + srv_proto['display_name'] = protocol_display_name(protocol) + srv_proto['container_name'] = protocol_container_name(protocol) + if result.get('domain'): + srv_proto['domain'] = result['domain'] + save_data(data) + return result + except Exception as e: + logger.exception("Error updating Hysteria settings") + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"]) async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): if not _check_admin(request): diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py index 05a1b4e..1a41f2c 100644 --- a/managers/hysteria_manager.py +++ b/managers/hysteria_manager.py @@ -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) diff --git a/templates/server.html b/templates/server.html index 493f937..22d65ef 100644 --- a/templates/server.html +++ b/templates/server.html @@ -747,6 +747,33 @@ + + +