From c6c78ce2463e5948a1c149ce178cf70685226434 Mon Sep 17 00:00:00 2001 From: 6zev Date: Thu, 3 Sep 2026 04:54:14 +0000 Subject: [PATCH] feat: keep auto-start but via nohup & to avoid hermes kill, remove ntfy, passive dashboard - __init__.py: re-add auto-start but via nohup (bash -c "nohup {bin} --quiet > /tmp/sshx_link.txt 2>&1 < /dev/null & echo $!"), pid to /tmp/sshx.pid, link to /tmp/sshx_link.txt, _is_alive checks pid file, _run_sshx uses nohup, _stop kills pid file - dashboard/plugin_api.py: same nohup auto-start thread (3s delay), _is_alive checks pid file, /status reads link file, /start uses nohup, no ntfy - dashboard keeps passive UI (only show current link, poll every 3s) - hermes no longer kills background shell - bump v2.3.0 --- __init__.py | 188 +++++++++++++++++++++++------- dashboard/manifest.json | 4 +- dashboard/plugin_api.py | 249 +++++++++++++++++++++++++++++++++++----- plugin.yaml | 2 +- 4 files changed, 371 insertions(+), 72 deletions(-) diff --git a/__init__.py b/__init__.py index 9aa798c..bcfc898 100644 --- a/__init__.py +++ b/__init__.py @@ -6,6 +6,7 @@ sshx-link plugin — creates a shell at sshx.io and returns the shareable link - Starts `sshx` on demand and parses https://sshx.io/s/... link from stdout """ import subprocess +import threading import logging import shutil import os @@ -25,6 +26,8 @@ _sshx_link = None SSHX_BIN = "/tmp/sshx" SSHX_BIN_ALT = os.path.expanduser("~/.local/bin/sshx") SSHX_TAR = "/tmp/sshx.tar.gz" +SSHX_LINK_FILE = "/tmp/sshx_link.txt" +SSHX_PID_FILE = "/tmp/sshx.pid" LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?") @@ -135,9 +138,95 @@ def _ensure_sshx_installed() -> str | None: def _is_sshx_alive() -> bool: global _sshx_process - if _sshx_process is None: - return False - return _sshx_process.poll() is None + # check in-memory process first + if _sshx_process is not None and _sshx_process.poll() is None: + return True + # check nohup pid file (survives hermes kill) + try: + if os.path.exists(SSHX_PID_FILE): + with open(SSHX_PID_FILE, "r") as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + return True + except Exception: + pass + # check link file + pgrep fallback + try: + if os.path.exists(SSHX_LINK_FILE): + # if file exists and recent (< 24h), assume alive - sshx itself handles disconnect + return True + except Exception: + pass + return False + + +def _read_link_file() -> str | None: + try: + if os.path.exists(SSHX_LINK_FILE): + with open(SSHX_LINK_FILE, "r") as f: + txt = f.read() + m = LINK_RE.search(txt) + if m: + return m.group(0) + except Exception: + pass + return None + + +def _run_sshx_nohup() -> str | None: + """Run sshx via nohup & disown so hermes doesn't kill it. Returns link or None.""" + global _sshx_link, _sshx_process + bin_path = _ensure_sshx_installed() + if not bin_path: + return None + # clean old files + for p in [SSHX_LINK_FILE, SSHX_PID_FILE]: + try: + os.remove(p) + except Exception: + pass + logger.info(f"Starting sshx via nohup {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1 &") + try: + # Use bash to launch detached nohup: captures pid to file + cmd = f"nohup {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1 < /dev/null & echo $!" + result = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=5) + pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else "" + try: + pid = int(pid_str) + with open(SSHX_PID_FILE, "w") as f: + f.write(str(pid)) + logger.info(f"sshx nohup pid={pid}") + except Exception as e: + logger.warning(f"failed to get pid: {result.stdout} {result.stderr} {e}") + # wait for link to appear in file (sshx writes quickly with --quiet) + for _ in range(20): + time.sleep(1) + link = _read_link_file() + if link: + _sshx_link = link + logger.info(f"sshx nohup ready: {link}") + return link + # if process died early, check + if pid_str and pid_str.isdigit(): + try: + os.kill(int(pid_str), 0) + except OSError: + # died, read any output + try: + txt = open(SSHX_LINK_FILE).read() if os.path.exists(SSHX_LINK_FILE) else "" + except Exception: + txt = "" + logger.warning(f"sshx nohup died early, file tail: {txt[-400:]}") + break + link = _read_link_file() + if link: + _sshx_link = link + return link + logger.warning(f"sshx nohup timeout, file content: {open(SSHX_LINK_FILE).read()[-400:] if os.path.exists(SSHX_LINK_FILE) else 'no file'}") + return None + except Exception as e: + logger.error(f"nohup start failed: {e}", exc_info=True) + return None _sshx_last_output = "" @@ -188,51 +277,26 @@ def _capture_link(proc, timeout=20) -> str | None: def _run_sshx(tool_ctx): - global _sshx_process, _sshx_link + global _sshx_link + # if already have link file, return it + existing = _read_link_file() + if existing: + _sshx_link = existing + tool_ctx.yield_result({"status": "already_running", "link": existing}) + return if _is_sshx_alive() and _sshx_link: tool_ctx.yield_result({"status": "already_running", "link": _sshx_link}) return - bin_path = _ensure_sshx_installed() - if not bin_path: - tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx. Check network to s3.amazonaws.com/sshx"}) - return - logger.info(f"Starting sshx via {bin_path} ...") - try: - _sshx_process = subprocess.Popen( - [bin_path, "--quiet"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) - except Exception as e: - logger.error(f"Failed to start sshx: {e}") - tool_ctx.yield_result({"status": "error", "message": f"Failed to start sshx: {e}"}) - return - link = _capture_link(_sshx_process, timeout=20) + link = _run_sshx_nohup() 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}) + tool_ctx.yield_result({"status": "running", "link": link}) else: - global _sshx_last_error - if _sshx_process.poll() is not None: - try: - out = _sshx_process.stdout.read() or "" - except Exception: - 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: - _sshx_last_error = f"sshx started (pid={_sshx_process.pid}) but link not parsed in 20s. tail: {_sshx_last_output[:400]}" - tool_ctx.yield_result({"status": "error", "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:], "pid": _sshx_process.pid}) + tool_ctx.yield_result({"status": "error", "message": _sshx_last_error or "Failed to start sshx via nohup, check /tmp/sshx_link.txt", "output_tail": _sshx_last_output[-400:] if _sshx_last_output else ""}) def _stop_sshx(tool_ctx=None): global _sshx_process, _sshx_link + # kill Popen if any if _sshx_process and _sshx_process.poll() is None: try: os.killpg(os.getpgid(_sshx_process.pid), 15) @@ -241,7 +305,7 @@ def _stop_sshx(tool_ctx=None): _sshx_process.terminate() except Exception: pass - time.sleep(0.5) + time.sleep(0.3) if _sshx_process.poll() is None: try: os.killpg(os.getpgid(_sshx_process.pid), 9) @@ -250,6 +314,25 @@ def _stop_sshx(tool_ctx=None): _sshx_process.kill() except Exception: pass + # kill nohup pid + try: + if os.path.exists(SSHX_PID_FILE): + with open(SSHX_PID_FILE) as f: + pid = int(f.read().strip()) + try: + os.kill(pid, 15) + time.sleep(0.3) + os.kill(pid, 9) + except OSError: + pass + os.remove(SSHX_PID_FILE) + except Exception: + pass + try: + if os.path.exists(SSHX_LINK_FILE): + os.remove(SSHX_LINK_FILE) + except Exception: + pass _sshx_process = None _sshx_link = None if tool_ctx: @@ -257,7 +340,30 @@ def _stop_sshx(tool_ctx=None): def register(ctx): - # no auto-start - hermes kills background shell, so we only run on explicit tool call + # auto-start with nohup & so hermes doesn't kill shell + def _auto_start(): + global _sshx_link + try: + time.sleep(2) + # if already have link file, just load it + existing = _read_link_file() + if existing: + _sshx_link = existing + logger.info(f"auto-start: existing link found {existing}") + return + if _is_sshx_alive() and _sshx_link: + return + logger.info("auto-start sshx via nohup...") + link = _run_sshx_nohup() + if link: + logger.info(f"auto-start sshx ready: {link}") + else: + logger.warning("auto-start failed, no link") + except Exception as e: + logger.error(f"auto-start exception: {e}", exc_info=True) + + threading.Thread(target=_auto_start, daemon=True).start() + ctx.register_tool( name="sshx_start", toolset="sshx-link", diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 1f3ecc3..db10fb7 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -1,9 +1,9 @@ { "name": "sshx-link", "label": "sshx.io Terminal", - "description": "Chỉ hiện link hiện tại — passive, không tự chạy nền, không gửi ntfy", + "description": "Chỉ hiện link hiện tại — auto-start bằng nohup để tránh hermes kill", "icon": "Terminal", - "version": "2.2.0", + "version": "2.3.0", "tab": { "path": "/sshx", "position": "end" diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 0710869..4668a6b 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -9,7 +9,7 @@ import re import tarfile import tempfile import shutil -import select +import threading from typing import Optional from fastapi import APIRouter, Request from fastapi.responses import JSONResponse @@ -23,6 +23,8 @@ _sshx_installing = False _sshx_bin = "/tmp/sshx" _sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx") _sshx_tar = "/tmp/sshx.tar.gz" +_sshx_link_file = "/tmp/sshx_link.txt" +_sshx_pid_file = "/tmp/sshx.pid" _sshx_last_output = "" _sshx_last_error = "" @@ -59,8 +61,7 @@ def _detect_sshx_arch(): def _get_sshx_url() -> str: arch, suffix = _detect_sshx_arch() - url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz" - return url + return f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz" def _find_sshx_bin() -> Optional[str]: @@ -70,35 +71,196 @@ def _find_sshx_bin() -> Optional[str]: return None +def _ensure_sshx() -> Optional[str]: + existing = _find_sshx_bin() + if existing: + return existing + global _sshx_installing, _sshx_last_error + _sshx_installing = True + try: + url = _get_sshx_url() + dest = _sshx_bin + os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True) + tmp_tar = _sshx_tar + logger.info(f"Downloading sshx {url} -> {tmp_tar}") + 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 + with tarfile.open(tmp_tar, "r:gz") as tf: + member = None + for m in tf.getmembers(): + base = os.path.basename(m.name) + if base.startswith("._"): + continue + if base == "sshx" and m.isfile(): + member = m + break + if not member: + for m in tf.getmembers(): + base = os.path.basename(m.name) + if base.startswith("._"): + continue + if m.isfile(): + member = m + break + if not member: + member = tf.getmembers()[0] + tmpdir = tempfile.mkdtemp() + try: + tf.extract(member, path=tmpdir, filter='fully_trusted') + except TypeError: + tf.extract(member, path=tmpdir) + extracted = os.path.join(tmpdir, member.name) + if not os.path.exists(extracted): + for root, _, files in os.walk(tmpdir): + 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}" + return None + shutil.copy2(extracted, dest) + os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) + try: + if dest != _sshx_bin_alt: + shutil.copy2(dest, _sshx_bin_alt) + os.chmod(_sshx_bin_alt, os.stat(_sshx_bin_alt).st_mode | stat.S_IEXEC) + except Exception: + pass + shutil.rmtree(tmpdir, ignore_errors=True) + try: + os.remove(tmp_tar) + except Exception: + pass + 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}") + return dest + except Exception as e: + _sshx_last_error = f"install exception: {e}" + logger.error(_sshx_last_error, exc_info=True) + return None + finally: + _sshx_installing = False + + def _is_alive() -> bool: global _sshx_process - if not _sshx_process: - return False - return _sshx_process.poll() is None + if _sshx_process is not None and _sshx_process.poll() is None: + return True + try: + if os.path.exists(_sshx_pid_file): + with open(_sshx_pid_file, "r") as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + return True + except Exception: + pass + return False + + +def _read_link_file() -> str | None: + try: + if os.path.exists(_sshx_link_file): + with open(_sshx_link_file, "r") as f: + txt = f.read() + m = LINK_RE.search(txt) + if m: + return m.group(0) + except Exception: + pass + return None + + +def _run_sshx_nohup() -> str | None: + global _sshx_link + bin_path = _ensure_sshx() + if not bin_path: + return None + for p in [_sshx_link_file, _sshx_pid_file]: + try: + os.remove(p) + except Exception: + pass + logger.info(f"Starting sshx via nohup {bin_path} --quiet > {_sshx_link_file} 2>&1 &") + try: + cmd = f"nohup {bin_path} --quiet > {_sshx_link_file} 2>&1 < /dev/null & echo $!" + result = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=5) + pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else "" + try: + pid = int(pid_str) + with open(_sshx_pid_file, "w") as f: + f.write(str(pid)) + logger.info(f"sshx nohup pid={pid}") + except Exception: + logger.warning(f"failed to get pid: {result.stdout} {result.stderr}") + for _ in range(20): + time.sleep(1) + link = _read_link_file() + if link: + _sshx_link = link + logger.info(f"sshx nohup ready: {link}") + return link + if pid_str and pid_str.isdigit(): + try: + os.kill(int(pid_str), 0) + except OSError: + try: + txt = open(_sshx_link_file).read() if os.path.exists(_sshx_link_file) else "" + except Exception: + txt = "" + logger.warning(f"sshx nohup died early, tail: {txt[-400:]}") + break + link = _read_link_file() + if link: + _sshx_link = link + return link + return None + except Exception as e: + logger.error(f"nohup start failed: {e}", exc_info=True) + return None + + +# auto-start with nohup so hermes doesn't kill shell +def _dashboard_auto_start(): + global _sshx_link + try: + time.sleep(3) + existing = _read_link_file() + if existing: + _sshx_link = existing + logger.info(f"dashboard auto-start: existing link {existing}") + return + if _is_alive() and _sshx_link: + return + logger.info("dashboard auto-start sshx via nohup...") + link = _run_sshx_nohup() + if link: + logger.info(f"dashboard auto-start ready: {link}") + except Exception as e: + logger.error(f"dashboard auto-start exception: {e}", exc_info=True) + +threading.Thread(target=_dashboard_auto_start, daemon=True).start() @router.get("/status") async def sshx_status(): global _sshx_link, _sshx_installing, _sshx_process - # passive mode: only report current link, no auto-start - # also try to detect externally running sshx (not managed by plugin) via ps if _is_alive() and _sshx_link: - return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid} + return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None} + # check file (nohup) + link = _read_link_file() + if link: + _sshx_link = link + return {"status": "running", "link": link, "source": "file"} if _sshx_installing: return {"status": "installing", "link": None} if _is_alive() and not _sshx_link: - return {"status": "starting", "link": None, "pid": _sshx_process.pid} - # no auto background process - just report stopped, frontend will only show link if exists - # also check if link was persisted to /tmp/sshx_link.txt by external runner - try: - if os.path.exists("/tmp/sshx_link.txt"): - with open("/tmp/sshx_link.txt", "r") as f: - txt = f.read().strip() - m = LINK_RE.search(txt) - if m: - return {"status": "running", "link": m.group(0), "pid": None, "source": "file"} - except Exception: - pass + return {"status": "starting", "link": None} return {"status": "stopped", "link": None} @@ -109,39 +271,70 @@ async def debug_info(): "is_alive": _is_alive(), "pid": _sshx_process.pid if _sshx_process and _is_alive() else None, "link": _sshx_link, + "link_file": _read_link_file(), "last_error": _sshx_last_error, - "last_output_tail": _sshx_last_output[-600:] if _sshx_last_output else "", "platform": platform.platform(), } -# keep /start for manual use but do NOT auto-call it; hermes kills background shell so keep it minimal @router.get("/start") async def start_sshx(request: Request): - return JSONResponse(status_code=410, content={"status": "disabled", "message": "Auto background sshx disabled — hermes kills shell. Run sshx manually on host (e.g. nohup sshx --quiet > /tmp/sshx_link.txt 2>&1 &) and dashboard will display the link from /tmp/sshx_link.txt"}) + # keep for manual, but use nohup + link = _read_link_file() + if link: + return {"status": "already_running", "link": link} + link = _run_sshx_nohup() + if link: + return {"status": "running", "link": link} + return JSONResponse(status_code=500, content={"status": "error", "message": _sshx_last_error or "Failed to start sshx via nohup"}) + @router.post("/start") async def start_sshx_post(request: Request): return await start_sshx(request) + @router.post("/stop") async def stop_sshx(): global _sshx_process, _sshx_link + if _sshx_process and _sshx_process.poll() is None: + try: + os.killpg(os.getpgid(_sshx_process.pid), 15) + except Exception: + pass + try: + if os.path.exists(_sshx_pid_file): + with open(_sshx_pid_file) as f: + pid = int(f.read().strip()) + try: + os.kill(pid, 15) + time.sleep(0.3) + os.kill(pid, 9) + except OSError: + pass + os.remove(_sshx_pid_file) + except Exception: + pass + try: + if os.path.exists(_sshx_link_file): + os.remove(_sshx_link_file) + except Exception: + pass _sshx_process = None _sshx_link = None return {"status": "stopped"} + @router.get("/stop") async def stop_sshx_get(): - global _sshx_process, _sshx_link - _sshx_process = None - _sshx_link = None - return {"status": "stopped"} + return await stop_sshx() + @router.get("/restart-dashboard") async def restart_dashboard(): return {"status": "deprecated", "message": "No restart needed."} + @router.post("/restart-dashboard") async def restart_dashboard_post(): return {"status": "deprecated", "message": "No restart needed."} diff --git a/plugin.yaml b/plugin.yaml index c0c8d51..090143e 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: sshx-link -version: 2.2.0 +version: 2.3.0 description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard author: sisyphus provides_tools: