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
This commit is contained in:
6zev 2026-09-03 04:54:14 +00:00
parent e6a9893d4c
commit c6c78ce246
4 changed files with 371 additions and 72 deletions

View File

@ -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 - Starts `sshx` on demand and parses https://sshx.io/s/... link from stdout
""" """
import subprocess import subprocess
import threading
import logging import logging
import shutil import shutil
import os import os
@ -25,6 +26,8 @@ _sshx_link = None
SSHX_BIN = "/tmp/sshx" SSHX_BIN = "/tmp/sshx"
SSHX_BIN_ALT = os.path.expanduser("~/.local/bin/sshx") SSHX_BIN_ALT = os.path.expanduser("~/.local/bin/sshx")
SSHX_TAR = "/tmp/sshx.tar.gz" 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\"']*)?") 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: def _is_sshx_alive() -> bool:
global _sshx_process global _sshx_process
if _sshx_process is None: # check in-memory process first
return False if _sshx_process is not None and _sshx_process.poll() is None:
return _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 = "" _sshx_last_output = ""
@ -188,51 +277,26 @@ def _capture_link(proc, timeout=20) -> str | None:
def _run_sshx(tool_ctx): 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: if _is_sshx_alive() and _sshx_link:
tool_ctx.yield_result({"status": "already_running", "link": _sshx_link}) tool_ctx.yield_result({"status": "already_running", "link": _sshx_link})
return return
bin_path = _ensure_sshx_installed() link = _run_sshx_nohup()
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)
if link: if link:
_sshx_link = link tool_ctx.yield_result({"status": "running", "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: else:
global _sshx_last_error 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 ""})
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})
def _stop_sshx(tool_ctx=None): def _stop_sshx(tool_ctx=None):
global _sshx_process, _sshx_link global _sshx_process, _sshx_link
# kill Popen if any
if _sshx_process and _sshx_process.poll() is None: if _sshx_process and _sshx_process.poll() is None:
try: try:
os.killpg(os.getpgid(_sshx_process.pid), 15) os.killpg(os.getpgid(_sshx_process.pid), 15)
@ -241,7 +305,7 @@ def _stop_sshx(tool_ctx=None):
_sshx_process.terminate() _sshx_process.terminate()
except Exception: except Exception:
pass pass
time.sleep(0.5) time.sleep(0.3)
if _sshx_process.poll() is None: if _sshx_process.poll() is None:
try: try:
os.killpg(os.getpgid(_sshx_process.pid), 9) os.killpg(os.getpgid(_sshx_process.pid), 9)
@ -250,6 +314,25 @@ def _stop_sshx(tool_ctx=None):
_sshx_process.kill() _sshx_process.kill()
except Exception: except Exception:
pass 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_process = None
_sshx_link = None _sshx_link = None
if tool_ctx: if tool_ctx:
@ -257,7 +340,30 @@ def _stop_sshx(tool_ctx=None):
def register(ctx): 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( ctx.register_tool(
name="sshx_start", name="sshx_start",
toolset="sshx-link", toolset="sshx-link",

View File

@ -1,9 +1,9 @@
{ {
"name": "sshx-link", "name": "sshx-link",
"label": "sshx.io Terminal", "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", "icon": "Terminal",
"version": "2.2.0", "version": "2.3.0",
"tab": { "tab": {
"path": "/sshx", "path": "/sshx",
"position": "end" "position": "end"

View File

@ -9,7 +9,7 @@ import re
import tarfile import tarfile
import tempfile import tempfile
import shutil import shutil
import select import threading
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -23,6 +23,8 @@ _sshx_installing = False
_sshx_bin = "/tmp/sshx" _sshx_bin = "/tmp/sshx"
_sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx") _sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx")
_sshx_tar = "/tmp/sshx.tar.gz" _sshx_tar = "/tmp/sshx.tar.gz"
_sshx_link_file = "/tmp/sshx_link.txt"
_sshx_pid_file = "/tmp/sshx.pid"
_sshx_last_output = "" _sshx_last_output = ""
_sshx_last_error = "" _sshx_last_error = ""
@ -59,8 +61,7 @@ def _detect_sshx_arch():
def _get_sshx_url() -> str: def _get_sshx_url() -> str:
arch, suffix = _detect_sshx_arch() arch, suffix = _detect_sshx_arch()
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz" return f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
return url
def _find_sshx_bin() -> Optional[str]: def _find_sshx_bin() -> Optional[str]:
@ -70,35 +71,196 @@ def _find_sshx_bin() -> Optional[str]:
return None 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: def _is_alive() -> bool:
global _sshx_process global _sshx_process
if not _sshx_process: if _sshx_process is not None and _sshx_process.poll() is None:
return False return True
return _sshx_process.poll() is None 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") @router.get("/status")
async def sshx_status(): async def sshx_status():
global _sshx_link, _sshx_installing, _sshx_process 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: 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: if _sshx_installing:
return {"status": "installing", "link": None} return {"status": "installing", "link": None}
if _is_alive() and not _sshx_link: if _is_alive() and not _sshx_link:
return {"status": "starting", "link": None, "pid": _sshx_process.pid} return {"status": "starting", "link": None}
# 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": "stopped", "link": None} return {"status": "stopped", "link": None}
@ -109,39 +271,70 @@ async def debug_info():
"is_alive": _is_alive(), "is_alive": _is_alive(),
"pid": _sshx_process.pid if _sshx_process and _is_alive() else None, "pid": _sshx_process.pid if _sshx_process and _is_alive() else None,
"link": _sshx_link, "link": _sshx_link,
"link_file": _read_link_file(),
"last_error": _sshx_last_error, "last_error": _sshx_last_error,
"last_output_tail": _sshx_last_output[-600:] if _sshx_last_output else "",
"platform": platform.platform(), "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") @router.get("/start")
async def start_sshx(request: Request): 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") @router.post("/start")
async def start_sshx_post(request: Request): async def start_sshx_post(request: Request):
return await start_sshx(request) return await start_sshx(request)
@router.post("/stop") @router.post("/stop")
async def stop_sshx(): async def stop_sshx():
global _sshx_process, _sshx_link 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_process = None
_sshx_link = None _sshx_link = None
return {"status": "stopped"} return {"status": "stopped"}
@router.get("/stop") @router.get("/stop")
async def stop_sshx_get(): async def stop_sshx_get():
global _sshx_process, _sshx_link return await stop_sshx()
_sshx_process = None
_sshx_link = None
return {"status": "stopped"}
@router.get("/restart-dashboard") @router.get("/restart-dashboard")
async def restart_dashboard(): async def restart_dashboard():
return {"status": "deprecated", "message": "No restart needed."} return {"status": "deprecated", "message": "No restart needed."}
@router.post("/restart-dashboard") @router.post("/restart-dashboard")
async def restart_dashboard_post(): async def restart_dashboard_post():
return {"status": "deprecated", "message": "No restart needed."} return {"status": "deprecated", "message": "No restart needed."}

View File

@ -1,5 +1,5 @@
name: sshx-link 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 description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard
author: sisyphus author: sisyphus
provides_tools: provides_tools: