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
+69
View File
@@ -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):
+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)
+93 -1
View File
@@ -747,6 +747,33 @@
</div>
</div>
<!-- ===== Hysteria Settings Modal ===== -->
<div class="modal-backdrop" id="hysteriaSettingsModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="hysteriaSettingsTitle">{{ _('hysteria_settings_title') }}</h2>
<button class="modal-close" onclick="closeModal('hysteriaSettingsModal')">×</button>
</div>
<input type="hidden" id="hysteriaSetProto" value="hysteria" />
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="hysteriaSetDomain" disabled>
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP)</label>
<input class="form-input" type="number" id="hysteriaSetPort" min="1" max="65535" value="8998">
<div class="form-hint">{{ _('hysteria_port_change_hint') }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('hysteriaSettingsModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveHysteriaSettings()" id="hysteriaSaveBtn">
<span id="hysteriaSaveBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="hysteriaSaveSpinner"></div>
</button>
</div>
</div>
</div>
<!-- ===== NGINX Site Modal ===== -->
<div class="modal-backdrop" id="siteConfigModal">
<div class="modal" style="max-width:680px;">
@@ -1562,6 +1589,14 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (protoBase(proto) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
<button class="btn btn-secondary btn-sm" onclick="openHysteriaSettings('${proto}')">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
showConnectionsSection();
} else {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
@@ -1625,6 +1660,13 @@
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (protoBase(proto) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openHysteriaSettings('${proto}')" style="flex:1">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
<button class="btn btn-secondary btn-sm" onclick="openInstallModal('${proto}')">${_('reinstall')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
@@ -1972,7 +2014,7 @@
updateNginxDnsHint();
} else if (base === 'hysteria') {
portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.disabled = false;
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('hysteria_port_hint');
hysteriaOpts.style.display = 'block';
@@ -2219,6 +2261,56 @@
}
}
// ========== Hysteria Settings (port) ==========
async function openHysteriaSettings(proto = 'hysteria') {
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
document.getElementById('hysteriaSetProto').value = proto;
document.getElementById('hysteriaSettingsTitle').textContent =
`${_('hysteria_settings_title')} — ${getProtoTitle(proto)}`;
document.getElementById('hysteriaSetDomain').value = data.domain || '';
// Prefer live/status port, then settings, then 8998
const livePort = (currentProtocolStatus[proto] || {}).port;
document.getElementById('hysteriaSetPort').value = livePort || data.port || 8998;
openModal('hysteriaSettingsModal');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function saveHysteriaSettings() {
const btn = document.getElementById('hysteriaSaveBtn');
const text = document.getElementById('hysteriaSaveBtnText');
const spinner = document.getElementById('hysteriaSaveSpinner');
const proto = document.getElementById('hysteriaSetProto').value || 'hysteria';
const port = parseInt(document.getElementById('hysteriaSetPort').value, 10);
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
btn.disabled = true;
text.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto, port,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
closeModal('hysteriaSettingsModal');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
}
}
// ========== Connection Management ==========
function selectProtocolForConns(proto) {
document.getElementById('connectionsSection').style.display = '';
+5 -1
View File
@@ -359,9 +359,13 @@
"hysteria_desc": "Hysteria 2 (QUIC/UDP) via teddysun/hysteria with Let's Encrypt TLS. Admin sets the domain at install time.",
"hysteria_domain": "Domain",
"hysteria_email": "Let's Encrypt email",
"hysteria_port_hint": "UDP port for Hysteria (default 443 — better for DPI). Port 80/TCP must be free for certificate issuance.",
"hysteria_port_hint": "UDP port for Hysteria (default 8998). Avoid 443 if nginx/xray already use UDP/TCP 443. Port 80/TCP must be free for certificate issuance.",
"hysteria_install_hint": "Point the domain A-record to this server before install. Port 80/TCP is used temporarily for Let's Encrypt HTTP-01 validation. BBR and salamander obfuscation are enabled automatically.",
"hysteria_dns_hint": "Create this DNS record before installation:",
"hysteria_settings_title": "Hysteria settings",
"hysteria_change_port": "Change port",
"hysteria_port_change_hint": "UDP listen port. If you see “address already in use”, pick a free port (e.g. 8443 or 8998), save, then re-import the client link.",
"hysteria_settings_saved": "Hysteria port updated",
"container_logs": "Container logs",
"logs_btn": "📋 Logs",
"logs_live": "Live",
+5 -1
View File
@@ -364,9 +364,13 @@
"hysteria_desc": "Hysteria 2 (QUIC/UDP) با تصویر teddysun/hysteria و TLS از Let's Encrypt. دامنه را ادمین هنگام نصب مشخص می‌کند.",
"hysteria_domain": "دامنه",
"hysteria_email": "ایمیل Let's Encrypt",
"hysteria_port_hint": "پورت UDP برای Hysteria (پیش‌فرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.",
"hysteria_port_hint": "پورت UDP برای Hysteria (پیش‌فرض 8998). اگر 443 اشغال است پورت دیگری بگذارید.",
"hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander به‌صورت خودکار فعال می‌شوند.",
"hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
"hysteria_settings_title": "تنظیمات Hysteria",
"hysteria_change_port": "تغییر پورت",
"hysteria_port_change_hint": "پورت UDP. اگر address already in use دیدید، پورت آزاد بگذارید (8443 یا 8998).",
"hysteria_settings_saved": "پورت Hysteria به‌روز شد",
"container_logs": "لاگ‌های کانتینر",
"logs_btn": "📋 Logs",
"logs_live": "زنده",
+5 -1
View File
@@ -364,9 +364,13 @@
"hysteria_desc": "Hysteria 2 (QUIC/UDP) via teddysun/hysteria avec TLS Let's Encrypt. Le domaine est défini par l'admin à l'installation.",
"hysteria_domain": "Domaine",
"hysteria_email": "Email Let's Encrypt",
"hysteria_port_hint": "Port UDP pour Hysteria (443 par défaut). Le port 80/TCP doit être libre pour le certificat.",
"hysteria_port_hint": "Port UDP pour Hysteria (8998 par défaut). Évitez 443 s'il est déjà pris.",
"hysteria_install_hint": "L'enregistrement A du domaine doit pointer vers ce serveur. BBR et obfuscation salamander sont activés automatiquement.",
"hysteria_dns_hint": "Créez cet enregistrement DNS avant l'installation :",
"hysteria_settings_title": "Paramètres Hysteria",
"hysteria_change_port": "Changer le port",
"hysteria_port_change_hint": "Port UDP. Si « address already in use », choisissez un port libre (8443 ou 8998).",
"hysteria_settings_saved": "Port Hysteria mis à jour",
"container_logs": "Logs du conteneur",
"logs_btn": "📋 Logs",
"logs_live": "Temps réel",
+5 -1
View File
@@ -359,9 +359,13 @@
"hysteria_desc": "Hysteria 2 (QUIC/UDP) на образе teddysun/hysteria с TLS от Let's Encrypt. Домен указывает админ при установке.",
"hysteria_domain": "Домен",
"hysteria_email": "Email для Let's Encrypt",
"hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.",
"hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 8998). Не ставьте 443, если он уже занят nginx/xray. Порт 80/TCP должен быть свободен для выпуска сертификата.",
"hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.",
"hysteria_dns_hint": "Перед установкой создайте DNS-запись:",
"hysteria_settings_title": "Настройки Hysteria",
"hysteria_change_port": "Сменить порт",
"hysteria_port_change_hint": "UDP-порт прослушивания. Если ошибка «address already in use» — выберите свободный порт (8443 или 8998), сохраните и заново импортируйте ссылку в клиент.",
"hysteria_settings_saved": "Порт Hysteria обновлён",
"container_logs": "Логи контейнера",
"logs_btn": "📋 Логи",
"logs_live": "В реальном времени",
+5 -1
View File
@@ -364,9 +364,13 @@
"hysteria_desc": "Hysteria 2QUIC/UDPteddysun/hysteria),支持 Let's Encrypt TLS。安装时由管理员指定域名。",
"hysteria_domain": "域名",
"hysteria_email": "Let's Encrypt 邮箱",
"hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。",
"hysteria_port_hint": "Hysteria UDP 端口(默认 8998)。若 443 已被占用请换端口。",
"hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。",
"hysteria_dns_hint": "安装前请创建此 DNS 记录:",
"hysteria_settings_title": "Hysteria 设置",
"hysteria_change_port": "更改端口",
"hysteria_port_change_hint": "UDP 监听端口。若提示 address already in use,请改用空闲端口(如 8443 或 8998)。",
"hysteria_settings_saved": "Hysteria 端口已更新",
"container_logs": "容器日志",
"logs_btn": "📋 日志",
"logs_live": "实时",