Make Hysteria UDP port selectable when 443 is busy.

Show an inline port field on the stopped card, a dedicated install port, and clearer bind-error hints so admins can switch to 8998/8443 without guessing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 02:45:24 +03:00
co-authored by Cursor
parent d480d149b6
commit 258dc9f100
3 changed files with 120 additions and 39 deletions
+7
View File
@@ -2708,6 +2708,13 @@ async def api_check_server(request: Request, server_id: int):
for key in ('domain', 'email', 'site_url'): for key in ('domain', 'email', 'site_url'):
if db_proto.get(key) not in (None, ''): if db_proto.get(key) not in (None, ''):
merged[key] = db_proto[key] merged[key] = db_proto[key]
if protocol_base(proto) == 'hysteria':
for key in ('domain', 'email'):
if db_proto.get(key) not in (None, '') and not merged.get(key):
merged[key] = db_proto[key]
# Keep a useful error even when merging preserved records
if db_proto.get('port') and not merged.get('port'):
merged['port'] = db_proto['port']
return merged return merged
def should_preserve_saved_protocol(proto, result=None, err=None): def should_preserve_saved_protocol(proto, result=None, err=None):
+30 -22
View File
@@ -156,33 +156,38 @@ class HysteriaManager:
elif diag['error']: elif diag['error']:
diag['error_summary'] = diag['error'] diag['error_summary'] = diag['error']
elif diag['exit_code'] not in (None, 0): elif diag['exit_code'] not in (None, 0):
# Pull last meaningful log line # Prefer bind/port conflicts over generic FATAL lines
bind_line = ''
last = '' last = ''
for line in reversed((diag['recent_logs'] or '').splitlines()): for raw in reversed((diag['recent_logs'] or '').splitlines()):
line = self._strip_ansi(line).strip() line = self._strip_ansi(raw).strip()
if not line: if not line:
continue continue
# Prefer the FATAL / bind error line # strip docker --timestamps prefix
if 'address already in use' in line.lower() or 'FATAL' in line or 'failed to load' in line.lower(): m_ts = re.match(r'^\d{4}-\d{2}-\d{2}T\S+\s+(.*)$', line)
if m_ts:
line = m_ts.group(1).strip()
low = line.lower()
if 'address already in use' in low and not bind_line:
bind_line = line
if ('FATAL' in line or 'failed to load' in low) and not last:
last = line last = line
break
if not last: if not last:
last = line last = line
if last: pick = bind_line or last
# strip docker timestamp prefix if present if pick and 'address already in use' in pick.lower():
if ' ' in last and (last[0].isdigit() or last[0] == '2'): m = re.search(r'listen udp :(\d+)', pick, re.I) or re.search(r':(\d+):\s*bind', pick)
last = last.split(' ', 1)[-1] p = m.group(1) if m else '?'
last = self._strip_ansi(last) if p == '?':
if 'address already in use' in last.lower(): m2 = re.search(r'udp\s*:(\d+)', pick, re.I)
m = re.search(r'listen udp :(\d+)', last, re.I) or re.search(r':(\d+):\s*bind', last) p = m2.group(1) if m2 else '?'
p = m.group(1) if m else '?' diag['error_summary'] = (
diag['error_summary'] = ( f'UDP port {p} already in use — change port below (try 8998 or 8443)'
f'UDP port {p} already in use — open Settings and choose another port' )
) diag['port_busy'] = True
else: elif pick:
# keep message short for badge short = re.sub(r'\s+', ' ', pick)[:160]
short = re.sub(r'\s+', ' ', last)[:160] diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
else: else:
diag['error_summary'] = f"exited with code {diag['exit_code']}" diag['error_summary'] = f"exited with code {diag['exit_code']}"
elif diag['status'] and diag['status'] != 'running': elif diag['status'] and diag['status'] != 'running':
@@ -244,6 +249,7 @@ class HysteriaManager:
'exit_code': diag.get('exit_code'), 'exit_code': diag.get('exit_code'),
'container_status': diag.get('status') or '', 'container_status': diag.get('status') or '',
'recent_logs': diag.get('recent_logs') or '', 'recent_logs': diag.get('recent_logs') or '',
'port_busy': bool(diag.get('port_busy')),
} }
# ===================== HELPERS ===================== # ===================== HELPERS =====================
@@ -460,8 +466,10 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP
def get_settings(self): def get_settings(self):
meta = self._ensure_secrets(self._read_metadata()) meta = self._ensure_secrets(self._read_metadata())
port = int(meta.get('port') or self.DEFAULT_PORT)
return { return {
'port': int(meta.get('port') or self.DEFAULT_PORT), 'port': port,
'suggested_port': 8998 if port in (443, 80) else port,
'domain': meta.get('domain') or '', 'domain': meta.get('domain') or '',
'email': meta.get('email') or '', 'email': meta.get('email') or '',
'protocol': self.protocol, 'protocol': self.protocol,
+83 -17
View File
@@ -574,7 +574,7 @@
</div> </div>
<div id="installForm"> <div id="installForm">
<div class="form-group"> <div class="form-group" id="installPortGroup">
<label class="form-label" id="installPortLabel">{{ _('port') }}</label> <label class="form-label" id="installPortLabel">{{ _('port') }}</label>
<input class="form-input" type="number" id="installPort" value="55424" min="1" max="65535"> <input class="form-input" type="number" id="installPort" value="55424" min="1" max="65535">
<div class="form-hint" id="installPortHint">{{ _('port_default_hint') }}</div> <div class="form-hint" id="installPortHint">{{ _('port_default_hint') }}</div>
@@ -681,6 +681,11 @@
<div id="hysteriaOptions" <div id="hysteriaOptions"
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);"> style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP) *</label>
<input class="form-input" type="number" id="installHysteriaPort" value="8998" min="1" max="65535">
<div class="form-hint">{{ _('hysteria_port_hint') }}</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label> <label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()"> <input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()">
@@ -1632,15 +1637,16 @@
} }
} else if (info.container_exists) { } else if (info.container_exists) {
installedProtocols[proto] = true; installedProtocols[proto] = true;
const errText = (info.error || '').trim(); const errText = String(info.error || '').trim();
const portBusy = !!info.port_busy || /address already in use|UDP port \d+ already in use|already in use/i.test(errText);
statusEl.innerHTML = errText statusEl.innerHTML = errText
? `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 80))}</span>` ? `<span class="badge badge-danger" style="max-width:100%; white-space:normal; text-align:left; line-height:1.35;"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 120))}</span>`
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`; : `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
if (infoEl && infoGrid) { if (infoEl && infoGrid) {
infoEl.classList.remove('hidden'); infoEl.classList.remove('hidden');
let grid = ''; let grid = '';
if (info.port) { if (info.port) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/UDP</span></div>`;
} }
if (info.container_status) { if (info.container_status) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('container_status')}</span><span class="protocol-info-value">${escapeHtml(String(info.container_status))}</span></div>`; grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('container_status')}</span><span class="protocol-info-value">${escapeHtml(String(info.container_status))}</span></div>`;
@@ -1651,6 +1657,26 @@
if (errText) { if (errText) {
grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`; grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`;
} }
// Inline port picker — always visible when Hysteria is stopped so admin can fix bind conflicts
if (protoBase(proto) === 'hysteria') {
const curPort = parseInt(info.port, 10) || 443;
const suggest = (curPort === 443 || portBusy) ? 8998 : curPort;
grid += `
<div class="protocol-info-item" style="grid-column:1/-1; display:block; padding-top:8px;">
<span class="protocol-info-label" style="display:block; margin-bottom:6px;">${_('hysteria_change_port')} (UDP)</span>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="form-input" type="number" id="hysteriaInlinePort-${protoDomKey(proto)}"
value="${suggest}" min="1" max="65535" style="width:120px; flex:0 0 auto;">
<button class="btn btn-primary btn-sm" type="button"
onclick="applyHysteriaPort('${proto}', document.getElementById('hysteriaInlinePort-${protoDomKey(proto)}').value)">
${_('save')} &amp; ${_('start_btn')}
</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openHysteriaSettings('${proto}')">${_('hysteria_settings_title')}</button>
</div>
<div class="form-hint" style="margin-top:6px;">${_('hysteria_port_change_hint')}</div>
</div>
`;
}
infoGrid.innerHTML = grid; infoGrid.innerHTML = grid;
} }
if (isService) { if (isService) {
@@ -1965,6 +1991,7 @@
const portInput = document.getElementById('installPort'); const portInput = document.getElementById('installPort');
const portLabel = document.getElementById('installPortLabel'); const portLabel = document.getElementById('installPortLabel');
const portHint = document.getElementById('installPortHint'); const portHint = document.getElementById('installPortHint');
const portGroup = document.getElementById('installPortGroup');
const telemtOpts = document.getElementById('telemtOptions'); const telemtOpts = document.getElementById('telemtOptions');
const socks5Opts = document.getElementById('socks5Options'); const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions'); const adguardOpts = document.getElementById('adguardOptions');
@@ -1976,6 +2003,7 @@
adguardOpts.style.display = 'none'; adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none'; nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none'; hysteriaOpts.style.display = 'none';
if (portGroup) portGroup.style.display = '';
if (base === 'dns') { if (base === 'dns') {
portLabel.textContent = _('port') + ' (Internal)'; portLabel.textContent = _('port') + ' (Internal)';
@@ -2013,11 +2041,17 @@
nginxOpts.style.display = 'block'; nginxOpts.style.display = 'block';
updateNginxDnsHint(); updateNginxDnsHint();
} else if (base === 'hysteria') { } else if (base === 'hysteria') {
portLabel.textContent = _('port') + ' (UDP)'; // Port is chosen inside hysteriaOptions (dedicated UDP field)
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998'; if (portGroup) portGroup.style.display = 'none';
const suggested = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.value = suggested;
portInput.disabled = false; portInput.disabled = false;
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('hysteria_port_hint');
hysteriaOpts.style.display = 'block'; hysteriaOpts.style.display = 'block';
const hyPort = document.getElementById('installHysteriaPort');
if (hyPort) {
hyPort.value = suggested;
hyPort.oninput = () => { portInput.value = hyPort.value; };
}
updateHysteriaDnsHint(); updateHysteriaDnsHint();
} else { } else {
portLabel.textContent = _('port') + ' (UDP)'; portLabel.textContent = _('port') + ' (UDP)';
@@ -2086,6 +2120,10 @@
params.nginx_domain = document.getElementById('installNginxDomain').value.trim(); params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
params.nginx_email = document.getElementById('installNginxEmail').value.trim(); params.nginx_email = document.getElementById('installNginxEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'hysteria') { } else if (protoBase(currentInstallProto) === 'hysteria') {
const hyPortEl = document.getElementById('installHysteriaPort');
if (hyPortEl && hyPortEl.value) {
params.port = hyPortEl.value;
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim(); params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim(); params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} }
@@ -2262,22 +2300,50 @@
} }
// ========== Hysteria Settings (port) ========== // ========== Hysteria Settings (port) ==========
async function openHysteriaSettings(proto = 'hysteria') { async function applyHysteriaPort(proto, portValue) {
const port = parseInt(portValue, 10);
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
try { try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`); showToast(_('saving') || 'Saving...', 'info');
document.getElementById('hysteriaSetProto').value = proto; const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
document.getElementById('hysteriaSettingsTitle').textContent = protocol: proto || 'hysteria', port,
`${_('hysteria_settings_title')} — ${getProtoTitle(proto)}`; });
document.getElementById('hysteriaSetDomain').value = data.domain || ''; showToast(res.message || _('hysteria_settings_saved'), 'success');
// Prefer live/status port, then settings, then 8998 setTimeout(() => checkServer(), 800);
const livePort = (currentProtocolStatus[proto] || {}).port;
document.getElementById('hysteriaSetPort').value = livePort || data.port || 8998;
openModal('hysteriaSettingsModal');
} catch (err) { } catch (err) {
showToast(_('error') + ': ' + err.message, 'error'); showToast(_('error') + ': ' + err.message, 'error');
// Still open settings so admin can try another port
openHysteriaSettings(proto);
} }
} }
async function openHysteriaSettings(proto = 'hysteria') {
const live = currentProtocolStatus[proto] || {};
const livePort = live.port || 443;
const suggest = (parseInt(livePort, 10) === 443) ? 8998 : (livePort || 8998);
document.getElementById('hysteriaSetProto').value = proto;
document.getElementById('hysteriaSettingsTitle').textContent =
`${_('hysteria_settings_title')} — ${getProtoTitle(proto)}`;
document.getElementById('hysteriaSetDomain').value = live.domain || '';
document.getElementById('hysteriaSetPort').value = suggest;
openModal('hysteriaSettingsModal');
// Enrich from server when possible (don't block UI if SSH is slow/fails)
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
if (data.domain) document.getElementById('hysteriaSetDomain').value = data.domain;
const p = parseInt(data.port, 10);
if (p && p !== 443) document.getElementById('hysteriaSetPort').value = p;
else if (p === 443) document.getElementById('hysteriaSetPort').value = 8998;
} catch (_) { /* modal already open with local values */ }
}
async function saveHysteriaSettings() { async function saveHysteriaSettings() {
const btn = document.getElementById('hysteriaSaveBtn'); const btn = document.getElementById('hysteriaSaveBtn');
const text = document.getElementById('hysteriaSaveBtnText'); const text = document.getElementById('hysteriaSaveBtnText');