From 39981e6f1862e7f9199f549e1731b7b6b109ca90 Mon Sep 17 00:00:00 2001 From: 6zev Date: Thu, 3 Sep 2026 04:23:49 +0000 Subject: [PATCH] fix: debug No link returned on 13.140.132.105 - improve error reporting + timeout - increase capture timeout 15->20s, keep output tail for diagnostics - plugin_api: add _sshx_last_output/_last_error, /debug endpoint with arch/url/binary/platform - /start now returns debug + output_tail on 500/504/202 so frontend shows real reason instead of 'No link returned' - /status returns error+tail, installing/starting now shows message - frontend: handle starting/installing/error with Debug view, show output_tail+debug JSON, refresh every 2.5s, Debug button - __init__.py: same capture+error improvements for CLI tool - bump v2.0.1 --- __init__.py | 25 ++++++----- dashboard/dist/index.js | 87 ++++++++++++++++++++++++------------- dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 96 +++++++++++++++++++++++++++++++---------- plugin.yaml | 2 +- 5 files changed, 147 insertions(+), 65 deletions(-) diff --git a/__init__.py b/__init__.py index 5635145..f34ff80 100644 --- a/__init__.py +++ b/__init__.py @@ -156,12 +156,14 @@ def _is_sshx_alive() -> bool: return _sshx_process.poll() is None -def _capture_link(proc, timeout=15) -> str | None: +_sshx_last_output = "" +_sshx_last_error = "" + +def _capture_link(proc, timeout=20) -> str | None: """Read stdout until link found or timeout. Returns link or None.""" + global _sshx_last_output link = None start = time.time() - # use non-blocking read via thread? simpler: read line with timeout via poll - # We'll read via proc.stdout readline with timeout loop import select output = "" @@ -201,9 +203,9 @@ def _capture_link(proc, timeout=15) -> str | None: m = LINK_RE.search(output) if m: link = m.group(0) - # also try reading logfile if we used file + _sshx_last_output = output[-2000:] if not link: - logger.warning(f"sshx capture timeout, output so far: {output[:500]}") + logger.warning(f"sshx capture timeout, output tail: {output[-600:]}") return link @@ -238,23 +240,24 @@ def _run_sshx(tool_ctx): tool_ctx.yield_result({"status": "error", "message": f"Failed to start sshx: {e}"}) return - link = _capture_link(_sshx_process, timeout=15) + link = _capture_link(_sshx_process, timeout=20) if link: _sshx_link = link logger.info(f"sshx running at {_sshx_link} pid={_sshx_process.pid}") tool_ctx.yield_result({"status": "running", "link": _sshx_link, "pid": _sshx_process.pid}) else: - # process may still be alive but link not captured — check if died + global _sshx_last_error if _sshx_process.poll() is not None: try: out = _sshx_process.stdout.read() or "" except Exception: - out = "" - tool_ctx.yield_result({"status": "error", "message": f"sshx exited quickly (code={_sshx_process.poll()}): {out[:300]}"}) + out = _sshx_last_output + _sshx_last_error = f"sshx exited quickly (code={_sshx_process.poll()}): {(_sshx_last_output or out)[:600]}" + tool_ctx.yield_result({"status": "error", "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:]}) _sshx_process = None else: - # still running but no link parsed — return with log hint - tool_ctx.yield_result({"status": "running", "link": None, "message": "sshx started but link not parsed yet, check logs", "pid": _sshx_process.pid}) + _sshx_last_error = f"sshx started (pid={_sshx_process.pid}) but link not parsed in 20s. tail: {_sshx_last_output[:400]}. Check outbound to https://sshx.io" + tool_ctx.yield_result({"status": "error", "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:], "pid": _sshx_process.pid}) def _stop_sshx(tool_ctx=None): diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index b0606da..a669d34 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -10,7 +10,7 @@ const res = await fetch(path, opts || {}); const text = await res.text(); try { return JSON.parse(text); } catch (_) { - throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,300)); + throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,800)); } } @@ -18,38 +18,53 @@ const [link, setLink] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); + const [debug, setDebug] = useState(null); + + const fetchDebug = useCallback(async function(){ + try { const d = await safeFetchJSON("/api/plugins/sshx-link/debug"); setDebug(d); } catch(_){} + }, []); const start = useCallback(async function () { setStatus("loading"); setError(null); + setDebug(null); try { - var res = await safeFetchJSON("/api/plugins/sshx-link/status"); - if (res.status === "installing") { + var st = await safeFetchJSON("/api/plugins/sshx-link/status"); + if (st.status === "installing" || st.status === "starting") { setStatus("installing"); + setError(st.message || "Đang xử lý..."); + fetchDebug(); return; } - if (res.status === "running" && res.link) { - setLink(res.link); + if (st.status === "running" && st.link) { + setLink(st.link); setStatus("running"); return; } - res = await safeFetchJSON("/api/plugins/sshx-link/start"); + if (st.status === "error" && st.message) { + // show previous error but try to restart anyway + } + var res = await safeFetchJSON("/api/plugins/sshx-link/start"); + // res may be 202 starting if (res.link) { setLink(res.link); setStatus("running"); } else if (res.status === "starting") { setStatus("installing"); - setError("sshx đang khởi động, thử lại sau 2s..."); - setTimeout(function(){ safeFetchJSON("/api/plugins/sshx-link/status").then(function(r){ if(r.link){ setLink(r.link); setStatus("running"); setError(null);} }); }, 2000); + setError(res.message || "sshx đang khởi động, chờ 2s rồi thử lại... " + (res.output_tail || "")); + fetchDebug(); } else { - setError(res.message || "No link returned"); + setError((res.message || JSON.stringify(res)) + (res.output_tail ? "\nTail: " + res.output_tail : "") + (res.debug ? "\nDebug: " + JSON.stringify(res.debug) : "")); setStatus("error"); + fetchDebug(); } } catch (err) { + // err may contain JSON body in message setError(err.message || "Failed to start sshx"); setStatus("error"); + fetchDebug(); } - }, []); + }, [fetchDebug]); const stop = useCallback(async function () { try { @@ -57,6 +72,7 @@ setLink(null); setStatus("idle"); setError(null); + setDebug(null); } catch (err) { setError(err.message); } @@ -68,8 +84,14 @@ if (res.status === "running" && res.link) { setLink(res.link); setStatus("running"); + setError(null); } else if (res.status === "installing" || res.status === "starting") { setStatus("installing"); + if (res.message) setError(res.message); + } else if (res.status === "error") { + setStatus("error"); + setError(res.message + (res.output_tail ? "\nTail: " + res.output_tail : "")); + setDebug(res); } else if (res.status === "stopped") { if (status === "running") { setLink(null); @@ -84,25 +106,27 @@ }, [checkStatus]); useEffect(function () { - if (status !== "installing") return; + if (status !== "installing" && status !== "loading") return; var timer = setInterval(function () { checkStatus(); - }, 2000); + }, 2500); return function () { clearInterval(timer); }; }, [status, checkStatus]); - if (status === "installing") { + if (status === "installing" || status === "loading") { return React.createElement(Card, null, React.createElement(CardHeader, null, - React.createElement(CardTitle, null, "sshx.io Shared Terminal") + React.createElement(CardTitle, null, "sshx.io Shared Terminal"), + React.createElement("p", { className: "text-xs text-yellow-500", style: { fontWeight: 600 } }, status==="loading" ? "Đang tạo shell ở sshx.io..." : "Đang cài / khởi động sshx...") ), React.createElement(CardContent, null, - React.createElement("p", { - className: "text-sm text-yellow-500", - style: { fontWeight: 600 } - }, "Đang cài / khởi động sshx (auto arch detect)..."), - React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Đang tải binary từ s3.amazonaws.com/sshx, không cần restart dashboard. Tự động thử lại sau 2s."), - error && React.createElement("p", { className: "text-xs text-yellow-700", style: { marginTop: "0.5rem"}}, error) + React.createElement("p", { className: "text-sm", style: { whiteSpace:"pre-wrap", fontWeight:600, color:"#eab308"}}, error || "Đang tải binary từ s3.amazonaws.com/sshx..."), + React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Tự động thử lại mỗi 2.5s. Nếu kẹt quá 20s, kiểm tra outbound tới s3.amazonaws.com và sshx.io trên server 13.140.132.105."), + debug && React.createElement("pre", { style:{marginTop:"0.75rem", fontSize:"0.68rem", background:"#111", padding:"0.5rem", borderRadius:"6px", overflow:"auto", maxHeight:"200px", whiteSpace:"pre-wrap"}}, JSON.stringify(debug, null, 2)), + React.createElement("div", { style:{display:"flex", gap:"0.5rem", marginTop:"0.75rem"}}, + React.createElement(Button, { onClick: checkStatus, variant:"outline", size:"sm"}, "Refresh"), + React.createElement(Button, { onClick: stop, variant:"destructive", size:"sm"}, "Stop") + ) ) ); } @@ -162,7 +186,7 @@ }) ), React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, - "Terminal nhúng ở trên (infinite canvas, cộng tác realtime). Nếu iframe bị chặn, bấm Open để mở tab mới. Link có dạng https://sshx.io/s/# — chia sẻ cho user là xong." + "Terminal nhúng ở trên. Nếu iframe bị chặn, bấm Open. Link dạng https://sshx.io/s/# — chia sẻ cho user là xong." ) ) ); @@ -177,16 +201,19 @@ React.createElement("p", { className: "text-sm text-muted-foreground", style: { marginBottom: "1rem" } - }, "Bấm Start để tạo shell cộng tác trên sshx.io. Plugin sẽ tự tải binary sshx (S3), chạy sshx và parse link https://sshx.io/s/... trả về cho user."), - error && React.createElement("p", { - className: "text-sm text-red-500", - style: { marginBottom: "1rem", whiteSpace: "pre-wrap" } + }, "Bấm Start để tạo shell cộng tác trên sshx.io. Plugin sẽ tự tải binary sshx (S3), chạy sshx --quiet và parse link https://sshx.io/s/..."), + error && React.createElement("pre", { + style: { marginBottom: "1rem", whiteSpace: "pre-wrap", fontSize:"0.85rem", color:"#ef4444", background:"#1a0a0a", padding:"0.5rem", borderRadius:"6px", overflow:"auto", maxHeight:"300px"} }, "Error: " + error), - React.createElement(Button, { - onClick: start, - disabled: status === "loading" - }, status === "loading" ? "Đang tạo shell ở sshx.io..." : "Start sshx → Lấy link"), - React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.75rem"}}, "Tương đương lệnh: curl -sSf https://sshx.io/get | sh -s run — nhưng tự động hoàn toàn trong dashboard.") + debug && React.createElement("pre", { style:{marginBottom:"1rem", fontSize:"0.68rem", background:"#111", padding:"0.5rem", borderRadius:"6px", overflow:"auto", maxHeight:"200px", whiteSpace:"pre-wrap"}}, "Debug: " + JSON.stringify(debug, null, 2)), + React.createElement("div", { style:{display:"flex", gap:"0.5rem", flexWrap:"wrap"}}, + React.createElement(Button, { + onClick: start, + disabled: status === "loading" + }, status === "loading" ? "Đang tạo shell..." : "Start sshx → Lấy link"), + React.createElement(Button, { onClick: fetchDebug, variant:"outline", size:"sm"}, "Debug") + ), + React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.75rem"}}, "Nếu lỗi 'No link returned' hoặc timeout, bấm Debug để xem arch/url/binary, kiểm tra server có ra được s3.amazonaws.com và sshx.io không (firewall/security group).") ) ); } diff --git a/dashboard/manifest.json b/dashboard/manifest.json index bbac14d..91c7a3a 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "sshx.io Terminal", "description": "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart", "icon": "Terminal", - "version": "2.0.0", + "version": "2.0.1", "tab": { "path": "/sshx", "position": "end" diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index a08452d..11e9042 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -24,6 +24,8 @@ _sshx_installing = False _sshx_bin = "/tmp/sshx" _sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx") _sshx_tar = "/tmp/sshx.tar.gz" +_sshx_last_output = "" +_sshx_last_error = "" LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?") @@ -74,7 +76,7 @@ def _ensure_sshx() -> Optional[str]: existing = _find_sshx_bin() if existing: return existing - global _sshx_installing + global _sshx_installing, _sshx_last_error _sshx_installing = True try: url = _get_sshx_url() @@ -82,8 +84,14 @@ def _ensure_sshx() -> Optional[str]: os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True) tmp_tar = _sshx_tar logger.info(f"Downloading sshx {url} -> {tmp_tar}") - urllib.request.urlretrieve(url, tmp_tar) - logger.info(f"Extracting {tmp_tar}") + # quick network check + try: + urllib.request.urlretrieve(url, tmp_tar) + except Exception as e: + _sshx_last_error = f"download failed {url}: {e}" + logger.error(_sshx_last_error) + return None + logger.info(f"Extracting {tmp_tar} size={os.path.getsize(tmp_tar) if os.path.exists(tmp_tar) else 0}") with tarfile.open(tmp_tar, "r:gz") as tf: member = None for m in tf.getmembers(): @@ -114,6 +122,10 @@ def _ensure_sshx() -> Optional[str]: if "sshx" in files: extracted = os.path.join(root, "sshx") break + if not extracted or not os.path.exists(extracted): + _sshx_last_error = f"extract failed: member={member.name} tmpdir={tmpdir}" + logger.error(_sshx_last_error) + return None shutil.copy2(extracted, dest) os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) try: @@ -127,10 +139,15 @@ def _ensure_sshx() -> Optional[str]: os.remove(tmp_tar) except Exception: pass - logger.info(f"sshx installed to {dest}") + # verify + if not os.path.exists(dest) or not os.access(dest, os.X_OK): + _sshx_last_error = f"binary not executable after install: {dest}" + return None + logger.info(f"sshx installed to {dest} size={os.path.getsize(dest)}") return dest except Exception as e: - logger.error(f"sshx install failed: {e}") + _sshx_last_error = f"install exception: {e}" + logger.error(_sshx_last_error, exc_info=True) return None finally: _sshx_installing = False @@ -166,7 +183,8 @@ def _kill_sshx(): _sshx_link = None -def _capture_link_sync(proc, timeout=15) -> Optional[str]: +def _capture_link_sync(proc, timeout=20) -> Optional[str]: + global _sshx_last_output link = None start = time.time() output = "" @@ -202,18 +220,27 @@ def _capture_link_sync(proc, timeout=15) -> Optional[str]: m = LINK_RE.search(output) if m: link = m.group(0) + _sshx_last_output = output[-2000:] # keep tail if not link: - logger.warning(f"sshx link not found after {timeout}s, output: {output[:600]}") + logger.warning(f"sshx link not found after {timeout}s, output tail: {output[-600:]}") return link def _start_sshx_background() -> bool: - global _sshx_process, _sshx_link + global _sshx_process, _sshx_link, _sshx_last_error, _sshx_last_output _kill_sshx() bin_path = _ensure_sshx() if not bin_path: + if not _sshx_last_error: + _sshx_last_error = "binary not found and install failed (no download)" return False - logger.info(f"Starting sshx {bin_path}") + # sanity check binary + try: + # quick version check + subprocess.run([bin_path, "--help"], timeout=2, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except Exception as e: + logger.warning(f"sshx help check failed: {e}") + logger.info(f"Starting sshx {bin_path} --quiet") try: proc = subprocess.Popen( [bin_path, "--quiet"], @@ -225,26 +252,32 @@ def _start_sshx_background() -> bool: start_new_session=True, ) _sshx_process = proc - link = _capture_link_sync(proc, timeout=15) + link = _capture_link_sync(proc, timeout=20) if link: _sshx_link = link + _sshx_last_error = "" logger.info(f"sshx running link={_sshx_link} pid={proc.pid}") return True - # if link not found but process still alive, keep it and return false to let caller handle + # link not found — keep process if alive for debug, but capture output if proc.poll() is None: - logger.warning("sshx started but link not parsed yet") + _sshx_last_error = f"sshx started (pid={proc.pid}) but link not parsed in 20s. output tail: {_sshx_last_output[:500]}. Check outbound to https://sshx.io, or firewall blocking. Binary={bin_path}" + logger.warning(_sshx_last_error) return False - logger.error(f"sshx exited quickly code={proc.poll()}") + # died + code = proc.poll() + _sshx_last_error = f"sshx exited quickly code={code} output: {_sshx_last_output[:800]}" + logger.error(_sshx_last_error) _sshx_process = None return False except Exception as e: - logger.error(f"Failed to start sshx: {e}") + _sshx_last_error = f"Failed to start sshx: {e}" + logger.error(_sshx_last_error, exc_info=True) return False @router.get("/start") async def start_sshx(request: Request): - global _sshx_link, _sshx_process + global _sshx_link, _sshx_process, _sshx_last_error, _sshx_last_output if _is_alive() and _sshx_link: return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None} @@ -253,17 +286,16 @@ async def start_sshx(request: Request): loop = asyncio.get_event_loop() bin_path = await loop.run_in_executor(None, _ensure_sshx) if not bin_path: - return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com"}) + return JSONResponse(status_code=500, content={"status": "error", "message": _sshx_last_error or "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com", "debug": {"arch": _detect_sshx_arch(), "url": _get_sshx_url(), "last_output": _sshx_last_output}}) loop = asyncio.get_event_loop() ok = await loop.run_in_executor(None, _start_sshx_background) if ok and _sshx_link: return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None} - # if process alive but link not yet, try one more check if _is_alive(): - return JSONResponse(status_code=202, content={"status": "starting", "message": "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None}) - return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not start in time, check logs"}) + return JSONResponse(status_code=202, content={"status": "starting", "message": _sshx_last_error or "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None, "output_tail": _sshx_last_output[-500:]}) + return JSONResponse(status_code=504, content={"status": "error", "message": _sshx_last_error or "sshx did not start in time, check logs and outbound to sshx.io/s3.amazonaws.com", "output_tail": _sshx_last_output[-800:], "debug": {"bin": _find_sshx_bin() or "not found", "arch": str(_detect_sshx_arch())}}) @router.post("/start") @@ -273,13 +305,15 @@ async def start_sshx_post(request: Request): @router.get("/status") async def sshx_status(): - global _sshx_link, _sshx_installing, _sshx_process + global _sshx_link, _sshx_installing, _sshx_process, _sshx_last_error, _sshx_last_output if _is_alive() and _sshx_link: return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid} if _sshx_installing: - return {"status": "installing", "link": None} + return {"status": "installing", "link": None, "message": "downloading sshx..."} if _is_alive() and not _sshx_link: - return {"status": "starting", "link": None, "pid": _sshx_process.pid} + return {"status": "starting", "link": None, "pid": _sshx_process.pid, "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:]} + if _sshx_last_error: + return {"status": "error", "link": None, "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:]} return {"status": "stopped", "link": None} @@ -295,6 +329,24 @@ async def stop_sshx_get(): return {"status": "stopped"} +@router.get("/debug") +async def debug_info(): + return { + "bin": _find_sshx_bin(), + "bin_exists": os.path.exists(_sshx_bin) if _sshx_bin else False, + "bin_alt_exists": os.path.exists(_sshx_bin_alt) if _sshx_bin_alt else False, + "arch": _detect_sshx_arch(), + "url": _get_sshx_url(), + "is_alive": _is_alive(), + "pid": _sshx_process.pid if _sshx_process and _is_alive() else None, + "link": _sshx_link, + "last_error": _sshx_last_error, + "last_output_tail": _sshx_last_output[-800:] if _sshx_last_output else "", + "platform": platform.platform(), + "machine": platform.machine(), + } + + # compat: old ttyd endpoint still works but now maps to sshx @router.get("/restart-dashboard") async def restart_dashboard(): diff --git a/plugin.yaml b/plugin.yaml index 64ac5c5..7830fa1 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: sshx-link -version: 2.0.0 +version: 2.0.1 description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard author: sisyphus provides_tools: