hermes-sshx-link/dashboard/plugin_api.py

341 lines
11 KiB
Python

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
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\"']*)?")
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 _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:
pid = int(f.read().strip())
os.kill(pid, 0)
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)
except Exception:
pass
return None
def _run_sshx_nohup() -> str | None:
global _sshx_link
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
logger.info(f"Starting sshx via nohup {bin_path} --quiet > {_sshx_link_file} 2>&1 &")
try:
cmd = f"nohup {bin_path} --quiet > {_sshx_link_file} 2>&1 < /dev/null & echo $!"
result = subprocess.run(["bash", "-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)
with open(_sshx_pid_file, "w") as f:
f.write(str(pid))
logger.info(f"sshx nohup pid={pid}")
except Exception:
logger.warning(f"failed to get pid: {result.stdout} {result.stderr}")
for _ in range(20):
time.sleep(1)
link = _read_link_file()
if link:
_sshx_link = link
logger.info(f"sshx nohup ready: {link}")
return link
if pid_str and pid_str.isdigit():
try:
os.kill(int(pid_str), 0)
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
return link
return None
except Exception as e:
logger.error(f"nohup start failed: {e}", exc_info=True)
return None
# auto-start with 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
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...")
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}
# check file (nohup)
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):
# keep for manual, but use nohup
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:
pid = int(f.read().strip())
try:
os.kill(pid, 15)
time.sleep(0.3)
os.kill(pid, 9)
except OSError:
pass
os.remove(_sshx_pid_file)
except Exception:
pass
try:
if os.path.exists(_sshx_link_file):
os.remove(_sshx_link_file)
except Exception:
pass
_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."}