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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1562,6 +1589,14 @@
`;
+ } else if (protoBase(proto) === 'hysteria') {
+ actionsEl.innerHTML = `
+
+
+
+
+ `;
+ showConnectionsSection();
} else {
actionsEl.innerHTML = `
@@ -1625,6 +1660,13 @@
`;
+ } else if (protoBase(proto) === 'hysteria') {
+ actionsEl.innerHTML = `
+
+
+
+
+ `;
} else {
actionsEl.innerHTML = `
@@ -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 = '';
diff --git a/translations/en.json b/translations/en.json
index cebbd95..06c7c31 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -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",
diff --git a/translations/fa.json b/translations/fa.json
index d957724..b44aa50 100644
--- a/translations/fa.json
+++ b/translations/fa.json
@@ -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": "زنده",
diff --git a/translations/fr.json b/translations/fr.json
index 8c43470..4de97e4 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -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",
diff --git a/translations/ru.json b/translations/ru.json
index 452d986..405f7c0 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -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": "В реальном времени",
diff --git a/translations/zh.json b/translations/zh.json
index 9d8dab6..a98dda0 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -364,9 +364,13 @@
"hysteria_desc": "Hysteria 2(QUIC/UDP,teddysun/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": "实时",