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'):
if db_proto.get(key) not in (None, ''):
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
def should_preserve_saved_protocol(proto, result=None, err=None):
+26 -18
View File
@@ -156,32 +156,37 @@ class HysteriaManager:
elif diag['error']:
diag['error_summary'] = diag['error']
elif diag['exit_code'] not in (None, 0):
# Pull last meaningful log line
# Prefer bind/port conflicts over generic FATAL lines
bind_line = ''
last = ''
for line in reversed((diag['recent_logs'] or '').splitlines()):
line = self._strip_ansi(line).strip()
for raw in reversed((diag['recent_logs'] or '').splitlines()):
line = self._strip_ansi(raw).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():
# strip docker --timestamps prefix
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
break
if not last:
last = line
if last:
# strip docker timestamp prefix if present
if ' ' in last and (last[0].isdigit() or last[0] == '2'):
last = last.split(' ', 1)[-1]
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)
pick = bind_line or last
if pick and 'address already in use' in pick.lower():
m = re.search(r'listen udp :(\d+)', pick, re.I) or re.search(r':(\d+):\s*bind', pick)
p = m.group(1) if m else '?'
if p == '?':
m2 = re.search(r'udp\s*:(\d+)', pick, re.I)
p = m2.group(1) if m2 else '?'
diag['error_summary'] = (
f'UDP port {p} already in use — open Settings and choose another port'
f'UDP port {p} already in use — change port below (try 8998 or 8443)'
)
else:
# keep message short for badge
short = re.sub(r'\s+', ' ', last)[:160]
diag['port_busy'] = True
elif pick:
short = re.sub(r'\s+', ' ', pick)[:160]
diag['error_summary'] = f"exit {diag['exit_code']}: {short}"
else:
diag['error_summary'] = f"exited with code {diag['exit_code']}"
@@ -244,6 +249,7 @@ class HysteriaManager:
'exit_code': diag.get('exit_code'),
'container_status': diag.get('status') or '',
'recent_logs': diag.get('recent_logs') or '',
'port_busy': bool(diag.get('port_busy')),
}
# ===================== HELPERS =====================
@@ -460,8 +466,10 @@ iptables -C INPUT -p udp --dport {port} -j ACCEPT 2>/dev/null || iptables -I INP
def get_settings(self):
meta = self._ensure_secrets(self._read_metadata())
port = int(meta.get('port') or self.DEFAULT_PORT)
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 '',
'email': meta.get('email') or '',
'protocol': self.protocol,
+82 -16
View File
@@ -574,7 +574,7 @@
</div>
<div id="installForm">
<div class="form-group">
<div class="form-group" id="installPortGroup">
<label class="form-label" id="installPortLabel">{{ _('port') }}</label>
<input class="form-input" type="number" id="installPort" value="55424" min="1" max="65535">
<div class="form-hint" id="installPortHint">{{ _('port_default_hint') }}</div>
@@ -681,6 +681,11 @@
<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);">
<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">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()">
@@ -1632,15 +1637,16 @@
}
} else if (info.container_exists) {
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
? `<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>`;
if (infoEl && infoGrid) {
infoEl.classList.remove('hidden');
let grid = '';
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) {
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) {
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;
}
if (isService) {
@@ -1965,6 +1991,7 @@
const portInput = document.getElementById('installPort');
const portLabel = document.getElementById('installPortLabel');
const portHint = document.getElementById('installPortHint');
const portGroup = document.getElementById('installPortGroup');
const telemtOpts = document.getElementById('telemtOptions');
const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions');
@@ -1976,6 +2003,7 @@
adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none';
if (portGroup) portGroup.style.display = '';
if (base === 'dns') {
portLabel.textContent = _('port') + ' (Internal)';
@@ -2013,11 +2041,17 @@
nginxOpts.style.display = 'block';
updateNginxDnsHint();
} else if (base === 'hysteria') {
portLabel.textContent = _('port') + ' (UDP)';
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
// Port is chosen inside hysteriaOptions (dedicated UDP field)
if (portGroup) portGroup.style.display = 'none';
const suggested = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.value = suggested;
portInput.disabled = false;
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('hysteria_port_hint');
hysteriaOpts.style.display = 'block';
const hyPort = document.getElementById('installHysteriaPort');
if (hyPort) {
hyPort.value = suggested;
hyPort.oninput = () => { portInput.value = hyPort.value; };
}
updateHysteriaDnsHint();
} else {
portLabel.textContent = _('port') + ' (UDP)';
@@ -2086,6 +2120,10 @@
params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
params.nginx_email = document.getElementById('installNginxEmail').value.trim();
} 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_email = document.getElementById('installHysteriaEmail').value.trim();
}
@@ -2262,20 +2300,48 @@
}
// ========== 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 {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
showToast(_('saving') || 'Saving...', 'info');
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto || 'hysteria', port,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
setTimeout(() => checkServer(), 800);
} catch (err) {
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 = data.domain || '';
// Prefer live/status port, then settings, then 8998
const livePort = (currentProtocolStatus[proto] || {}).port;
document.getElementById('hysteriaSetPort').value = livePort || data.port || 8998;
document.getElementById('hysteriaSetDomain').value = live.domain || '';
document.getElementById('hysteriaSetPort').value = suggest;
openModal('hysteriaSettingsModal');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
// 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() {