diff --git a/app.py b/app.py index 66511d6..f7b160b 100644 --- a/app.py +++ b/app.py @@ -1705,6 +1705,11 @@ class ProtocolRequest(BaseModel): protocol: str = 'awg' +class ContainerLogsRequest(BaseModel): + protocol: str = 'hysteria' + tail: Optional[int] = 200 + + class AddConnectionRequest(BaseModel): protocol: str = 'awg' name: str = 'Connection' @@ -3141,15 +3146,169 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe ssh.run_sudo_command(f"docker stop {container}") action = 'stopped' else: - ssh.run_sudo_command(f"docker start {container}") + # Hysteria: recreate with host network / current config instead of plain start + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + meta = mgr._read_metadata() + port = int(meta.get('port') or mgr.DEFAULT_PORT) + mgr._write_config_from_clients(port) + mgr._start_container(port) + else: + ssh.run_sudo_command(f"docker start {container}") action = 'started' + # After stop/start, return diagnostics for UI + error = '' + recent_logs = '' + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + diag = mgr.get_container_diagnostics(req.protocol) + error = diag.get('error_summary') or '' + recent_logs = diag.get('recent_logs') or '' + if action == 'started' and not diag.get('running'): + ssh.disconnect() + return JSONResponse({ + 'error': error or 'Hysteria failed to start', + 'action': action, + 'container': container, + 'recent_logs': recent_logs, + }, status_code=400) ssh.disconnect() - return {'status': 'success', 'action': action, 'container': container} + return { + 'status': 'success', + 'action': action, + 'container': container, + 'error': error, + 'recent_logs': recent_logs, + } except Exception as e: logger.exception("Error toggling container") return JSONResponse({'error': str(e)}, status_code=500) +@app.post('/api/servers/{server_id}/container/logs', tags=["Protocols"]) +async def api_container_logs(request: Request, server_id: int, req: ContainerLogsRequest): + """Fetch recent Docker logs for a protocol 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) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + server = data['servers'][server_id] + container = protocol_container_name(req.protocol) + ssh = get_ssh(server) + ssh.connect() + if protocol_base(req.protocol) == 'hysteria': + from managers.hysteria_manager import HysteriaManager + mgr = HysteriaManager(ssh, req.protocol) + logs = mgr.get_logs(req.protocol, tail=req.tail or 200) + diag = mgr.get_container_diagnostics(req.protocol) + ssh.disconnect() + return { + 'status': 'success', + 'protocol': req.protocol, + 'container': container, + 'logs': logs, + 'running': bool(diag.get('running')), + 'error': diag.get('error_summary') or '', + 'exit_code': diag.get('exit_code'), + 'container_status': diag.get('status') or '', + } + # Generic fallback for other protocols + tail = max(20, min(int(req.tail or 200), 2000)) + out, err, _ = ssh.run_sudo_command( + f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", + timeout=30, + ) + running_out, _, _ = ssh.run_sudo_command( + f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null" + ) + ssh.disconnect() + return { + 'status': 'success', + 'protocol': req.protocol, + 'container': container, + 'logs': (out or err or '').strip(), + 'running': running_out.strip().lower() == 'true', + 'error': '', + } + except Exception as e: + logger.exception("Error reading container logs") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/servers/{server_id}/container/logs/stream', tags=["Protocols"]) +async def api_container_logs_stream(request: Request, server_id: int, protocol: str = 'hysteria', tail: int = 100): + """SSE stream of docker logs (polls every ~1.5s for new lines).""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + if not is_valid_protocol(protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + server = data['servers'][server_id] + container = protocol_container_name(protocol) + if not container: + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + tail = max(20, min(int(tail or 100), 500)) + + async def event_gen(): + last_text = '' + try: + while True: + if await request.is_disconnected(): + break + + def _fetch(): + ssh = get_ssh(server) + ssh.connect() + try: + out, err, _ = ssh.run_sudo_command( + f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1", + timeout=25, + ) + running_out, _, _ = ssh.run_sudo_command( + f"docker inspect -f '{{{{.State.Status}}}}|{{{{.State.Running}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' {shlex.quote(container)} 2>/dev/null" + ) + return (out or err or '').strip(), (running_out or '').strip() + finally: + ssh.disconnect() + + logs, state = await asyncio.to_thread(_fetch) + if logs != last_text: + last_text = logs + payload = json.dumps({ + 'logs': logs, + 'state': state, + 'container': container, + 'ts': time.time(), + }, ensure_ascii=False) + yield f"data: {payload}\n\n" + else: + yield f": ping\n\n" + await asyncio.sleep(1.5) + except asyncio.CancelledError: + return + except Exception as e: + payload = json.dumps({'error': str(e)}, ensure_ascii=False) + yield f"data: {payload}\n\n" + + return StreamingResponse( + event_gen(), + media_type='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + ) + + @app.post('/api/servers/{server_id}/server_config', tags=["Protocols"]) async def api_server_config(request: Request, server_id: int, req: ProtocolRequest): """Get the raw server-side WireGuard/Xray configuration.""" diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py index 2554524..05a1b4e 100644 --- a/managers/hysteria_manager.py +++ b/managers/hysteria_manager.py @@ -91,12 +91,95 @@ class HysteriaManager: ) return 'Up' in out + def get_logs(self, protocol_type=None, tail=200): + """Return recent docker logs for the Hysteria container.""" + name = self._container_name(protocol_type or self.protocol) + tail = max(20, min(int(tail or 200), 2000)) + out, err, code = self.ssh.run_sudo_command( + f"docker logs --tail {tail} --timestamps {name} 2>&1", + timeout=30, + ) + text = (out or err or '').strip() + if code != 0 and not text: + text = f'(no logs: exit {code})' + return text + + def get_container_diagnostics(self, protocol_type=None): + """Inspect why the container is stopped / unhealthy.""" + name = self._container_name(protocol_type or self.protocol) + fmt = ( + '{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|' + '{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|' + '{{.State.StartedAt}}' + ) + out, _, code = self.ssh.run_sudo_command( + f"docker inspect -f '{fmt}' {name} 2>/dev/null" + ) + diag = { + 'status': 'unknown', + 'running': False, + 'exit_code': None, + 'oom_killed': False, + 'error': '', + 'finished_at': '', + 'started_at': '', + 'recent_logs': '', + 'error_summary': '', + } + if code != 0 or not (out or '').strip(): + diag['status'] = 'missing' + diag['error_summary'] = 'Container not found' + return diag + + parts = (out or '').strip().split('|') + while len(parts) < 7: + parts.append('') + status, running, exit_code, oom, err, finished, started = parts[:7] + diag['status'] = status or 'unknown' + diag['running'] = str(running).lower() == 'true' + try: + diag['exit_code'] = int(exit_code) + except Exception: + diag['exit_code'] = None + diag['oom_killed'] = str(oom).lower() == 'true' + diag['error'] = (err or '').strip() + diag['finished_at'] = (finished or '').strip() + diag['started_at'] = (started or '').strip() + diag['recent_logs'] = self.get_logs(protocol_type, tail=40) + + # Build human-readable summary for the UI badge + if diag['running']: + diag['error_summary'] = '' + elif diag['oom_killed']: + diag['error_summary'] = 'OOM killed (out of memory)' + elif diag['error']: + diag['error_summary'] = diag['error'] + elif diag['exit_code'] not in (None, 0): + # Pull last meaningful log line + last = '' + for line in reversed((diag['recent_logs'] or '').splitlines()): + line = line.strip() + if line: + last = line + break + if last: + # strip docker timestamp prefix if present + if ' ' in last and last[0].isdigit(): + last = last.split(' ', 1)[-1] + diag['error_summary'] = f"exit {diag['exit_code']}: {last[:180]}" + else: + diag['error_summary'] = f"exited with code {diag['exit_code']}" + elif diag['status'] and diag['status'] != 'running': + diag['error_summary'] = f"status: {diag['status']}" + return diag + def get_server_status(self, protocol_type=None): protocol_type = protocol_type or self.protocol exists = self.check_protocol_installed(protocol_type) running = self.check_container_running(protocol_type) meta = self._read_metadata() if exists else {} clients = self._read_clients() if exists else [] + diag = {} # Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs) if exists: @@ -120,6 +203,15 @@ class HysteriaManager: meta = self._read_metadata() except Exception as e: logger.warning('Hysteria migrate failed: %s', e) + diag = {'error_summary': f'migrate failed: {e}'} + + if exists: + try: + diag = self.get_container_diagnostics(protocol_type) + running = bool(diag.get('running')) + except Exception as e: + logger.warning('Hysteria diagnostics failed: %s', e) + diag = {'error_summary': str(e)} return { 'container_exists': exists, @@ -132,6 +224,10 @@ class HysteriaManager: 'base_protocol': self.PROTOCOL, 'instance': self._instance_index(protocol_type), 'container_name': self._container_name(protocol_type), + 'error': diag.get('error_summary') or '', + 'exit_code': diag.get('exit_code'), + 'container_status': diag.get('status') or '', + 'recent_logs': diag.get('recent_logs') or '', } # ===================== HELPERS ===================== diff --git a/templates/server.html b/templates/server.html index 6503a11..493f937 100644 --- a/templates/server.html +++ b/templates/server.html @@ -953,6 +953,29 @@ + + + {% endblock %} {% block scripts %} @@ -1552,17 +1575,21 @@ // unbound's static config), so we don't expose a raw editor for them. if (ctrlEl) { ctrlEl.style.cssText = ''; + const logsBtn = ``; if (protoBase(proto) === 'nginx') { ctrlEl.innerHTML = ` + ${logsBtn} `; } else if (isService) { ctrlEl.innerHTML = ` + ${logsBtn} `; } else { ctrlEl.innerHTML = ` + ${logsBtn} `; @@ -1570,33 +1597,59 @@ } } else if (info.container_exists) { installedProtocols[proto] = true; - statusEl.innerHTML = ` ${_('stop')}`; + const errText = (info.error || '').trim(); + statusEl.innerHTML = errText + ? ` ${_('stop')}: ${escapeHtml(errText.slice(0, 80))}` + : ` ${_('stop')}`; + if (infoEl && infoGrid) { + infoEl.classList.remove('hidden'); + let grid = ''; + if (info.port) { + grid += `
${_('port')}${info.port}
`; + } + if (info.container_status) { + grid += `
${_('container_status')}${escapeHtml(String(info.container_status))}
`; + } + if (info.exit_code !== undefined && info.exit_code !== null) { + grid += `
${_('exit_code')}${info.exit_code}
`; + } + if (errText) { + grid += `
${_('error')}${escapeHtml(errText)}
`; + } + infoGrid.innerHTML = grid; + } if (isService) { actionsEl.innerHTML = ` + `; } else { actionsEl.innerHTML = ` + `; } if (ctrlEl) { ctrlEl.style.cssText = ''; + const logsBtn = ``; if (protoBase(proto) === 'nginx') { ctrlEl.innerHTML = ` + ${logsBtn} `; } else if (isService) { ctrlEl.innerHTML = ` + ${logsBtn} `; } else { ctrlEl.innerHTML = ` + ${logsBtn} `; @@ -1624,13 +1677,131 @@ showToast(`${action} ${name}...`, 'info'); const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto }); showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success'); - // Recheck after a short delay setTimeout(() => checkServer(), 1500); } catch (err) { showToast(_('error') + ': ' + err.message, 'error'); + // Show live logs so admin can see why start failed + showContainerLogs(proto); + setTimeout(() => checkServer(), 800); } } + // ===== Live container logs ===== + let containerLogsES = null; + let containerLogsProto = ''; + + function renderContainerLogsText(text, stateStr) { + const out = document.getElementById('containerLogsOutput'); + const meta = document.getElementById('containerLogsMeta'); + if (!out) return; + const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40; + out.textContent = text || _('no_logs'); + if (atBottom) out.scrollTop = out.scrollHeight; + if (meta) { + const parts = String(stateStr || '').split('|'); + const status = parts[0] || ''; + const running = String(parts[1] || '').toLowerCase() === 'true'; + const exitCode = parts[2] || ''; + const err = parts[3] || ''; + let line = running + ? ` ${_('run')}` + : ` ${_('stop')}`; + if (status) line += ` · ${escapeHtml(status)}`; + if (exitCode !== '' && exitCode !== '0') line += ` · ${_('exit_code')}: ${escapeHtml(exitCode)}`; + if (err) line += ` · ${escapeHtml(err)}`; + meta.innerHTML = line; + } + } + + function stopContainerLogsStream() { + if (containerLogsES) { + try { containerLogsES.close(); } catch (_) {} + containerLogsES = null; + } + } + + function startContainerLogsStream(proto) { + stopContainerLogsStream(); + const live = document.getElementById('containerLogsLive'); + if (live && !live.checked) return; + const url = `/api/servers/${SERVER_ID}/container/logs/stream?protocol=${encodeURIComponent(proto)}&tail=200`; + containerLogsES = new EventSource(url); + containerLogsES.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.error) { + renderContainerLogsText(data.error, ''); + return; + } + renderContainerLogsText(data.logs || '', data.state || ''); + } catch (_) {} + }; + containerLogsES.onerror = () => { + // Browser will retry; show a soft hint once + const meta = document.getElementById('containerLogsMeta'); + if (meta && !meta.dataset.streamErr) { + meta.dataset.streamErr = '1'; + meta.innerHTML += ` · ${_('logs_reconnect')}`; + } + }; + } + + async function refreshContainerLogs() { + const proto = document.getElementById('containerLogsProto').value || containerLogsProto; + if (!proto) return; + try { + const res = await apiCall(`/api/servers/${SERVER_ID}/container/logs`, 'POST', { + protocol: proto, tail: 200, + }); + const state = [ + res.container_status || '', + res.running ? 'true' : 'false', + res.exit_code != null ? String(res.exit_code) : '', + res.error || '', + ].join('|'); + renderContainerLogsText(res.logs || '', state); + } catch (err) { + renderContainerLogsText(_('error') + ': ' + err.message, ''); + } + } + + function toggleContainerLogsLive() { + const live = document.getElementById('containerLogsLive'); + if (live && live.checked) { + startContainerLogsStream(containerLogsProto); + } else { + stopContainerLogsStream(); + } + } + + async function showContainerLogs(proto) { + containerLogsProto = proto; + document.getElementById('containerLogsProto').value = proto; + document.getElementById('containerLogsModalTitle').textContent = + `${_('container_logs')} — ${getProtoTitle(proto)}`; + document.getElementById('containerLogsOutput').textContent = _('loading'); + document.getElementById('containerLogsMeta').innerHTML = ''; + document.getElementById('containerLogsMeta').dataset.streamErr = ''; + const live = document.getElementById('containerLogsLive'); + if (live) live.checked = true; + openModal('containerLogsModal'); + await refreshContainerLogs(); + startContainerLogsStream(proto); + } + + function closeContainerLogsModal() { + stopContainerLogsStream(); + closeModal('containerLogsModal'); + } + + // Stop live stream if modal is closed via backdrop / Escape + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'containerLogsModal') stopContainerLogsStream(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') stopContainerLogsStream(); + }); + // View server-side WireGuard/Xray config async function showServerConfig(proto) { const name = getProtoTitle(proto); diff --git a/translations/en.json b/translations/en.json index 9b10e3f..cebbd95 100644 --- a/translations/en.json +++ b/translations/en.json @@ -362,6 +362,14 @@ "hysteria_port_hint": "UDP port for Hysteria (default 443 — better for DPI). 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:", + "container_logs": "Container logs", + "logs_btn": "📋 Logs", + "logs_live": "Live", + "logs_reconnect": "reconnecting…", + "no_logs": "No logs yet", + "container_status": "Status", + "exit_code": "Exit code", + "close": "Close", "tls_emulation": "TLS Emulation", "tls_domain": "Masking Domain", "max_connections_limit": "Max Connections", diff --git a/translations/fa.json b/translations/fa.json index 7d6af92..d957724 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -367,6 +367,13 @@ "hysteria_port_hint": "پورت UDP برای Hysteria (پیش‌فرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.", "hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander به‌صورت خودکار فعال می‌شوند.", "hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:", + "container_logs": "لاگ‌های کانتینر", + "logs_btn": "📋 Logs", + "logs_live": "زنده", + "logs_reconnect": "در حال اتصال مجدد…", + "no_logs": "هنوز لاگی نیست", + "container_status": "وضعیت", + "exit_code": "کد خروج", "dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", "telemt_port_hint": "Port for the Telegram proxy (typically 443).", diff --git a/translations/fr.json b/translations/fr.json index 5dd0622..8c43470 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -367,6 +367,13 @@ "hysteria_port_hint": "Port UDP pour Hysteria (443 par défaut). Le port 80/TCP doit être libre pour le certificat.", "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 :", + "container_logs": "Logs du conteneur", + "logs_btn": "📋 Logs", + "logs_live": "Temps réel", + "logs_reconnect": "reconnexion…", + "no_logs": "Pas encore de logs", + "container_status": "Statut", + "exit_code": "Code de sortie", "dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", "telemt_port_hint": "Port for the Telegram proxy (typically 443).", diff --git a/translations/ru.json b/translations/ru.json index e32164f..452d986 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -362,6 +362,14 @@ "hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.", "hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.", "hysteria_dns_hint": "Перед установкой создайте DNS-запись:", + "container_logs": "Логи контейнера", + "logs_btn": "📋 Логи", + "logs_live": "В реальном времени", + "logs_reconnect": "переподключение…", + "no_logs": "Логов пока нет", + "container_status": "Статус", + "exit_code": "Код выхода", + "close": "Закрыть", "tls_emulation": "TLS-эмуляция", "tls_domain": "Домен маскировки", "max_connections_limit": "Макс. соединений", diff --git a/translations/zh.json b/translations/zh.json index 8e46b6f..9d8dab6 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -367,6 +367,13 @@ "hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。", "hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。", "hysteria_dns_hint": "安装前请创建此 DNS 记录:", + "container_logs": "容器日志", + "logs_btn": "📋 日志", + "logs_live": "实时", + "logs_reconnect": "重新连接…", + "no_logs": "暂无日志", + "container_status": "状态", + "exit_code": "退出码", "dns_desc": "防止广告和保护隐私的个人 DNS 服务器。", "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", "telemt_port_hint": "Port for the Telegram proxy (typically 443).",