hermes-sshx-link/dashboard/plugin_api.py

307 lines
9.7 KiB
Python
Raw Normal View History

import asyncio
import subprocess
import os
import logging
import time
import platform
import stat
import urllib.request
import re
import tarfile
import tempfile
import shutil
import select
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"
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()
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
logger.info(f"sshx arch={arch} suffix={suffix} url={url}")
return url
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_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}")
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)
try:
os.remove(tmp_tar)
except Exception:
pass
logger.info(f"sshx installed to {dest}")
return dest
except Exception as e:
logger.error(f"sshx install failed: {e}")
return None
finally:
_sshx_installing = False
def _is_alive() -> bool:
global _sshx_process
if not _sshx_process:
return False
return _sshx_process.poll() is None
def _kill_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:
try:
_sshx_process.terminate()
except Exception:
pass
time.sleep(0.5)
if _sshx_process.poll() is None:
try:
os.killpg(os.getpgid(_sshx_process.pid), 9)
except Exception:
try:
_sshx_process.kill()
except Exception:
pass
_sshx_process = None
_sshx_link = None
def _capture_link_sync(proc, timeout=15) -> Optional[str]:
link = None
start = time.time()
output = ""
while time.time() - start < timeout:
if proc.poll() is not None:
try:
rem = proc.stdout.read() or ""
output += rem
except Exception:
pass
break
try:
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)
if not link:
logger.warning(f"sshx link not found after {timeout}s, output: {output[:600]}")
return link
def _start_sshx_background() -> bool:
global _sshx_process, _sshx_link
_kill_sshx()
bin_path = _ensure_sshx()
if not bin_path:
return False
logger.info(f"Starting sshx {bin_path}")
try:
proc = subprocess.Popen(
[bin_path, "--quiet"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
_sshx_process = proc
link = _capture_link_sync(proc, timeout=15)
if link:
_sshx_link = link
logger.info(f"sshx running link={_sshx_link} pid={proc.pid}")
return True
# if link not found but process still alive, keep it and return false to let caller handle
if proc.poll() is None:
logger.warning("sshx started but link not parsed yet")
return False
logger.error(f"sshx exited quickly code={proc.poll()}")
_sshx_process = None
return False
except Exception as e:
logger.error(f"Failed to start sshx: {e}")
return False
@router.get("/start")
async def start_sshx(request: Request):
global _sshx_link, _sshx_process
if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
bin_path = _find_sshx_bin()
if not bin_path:
loop = asyncio.get_event_loop()
bin_path = await loop.run_in_executor(None, _ensure_sshx)
if not bin_path:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com"})
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(None, _start_sshx_background)
if ok and _sshx_link:
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
# if process alive but link not yet, try one more check
if _is_alive():
return JSONResponse(status_code=202, content={"status": "starting", "message": "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None})
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not start in time, check logs"})
@router.post("/start")
async def start_sshx_post(request: Request):
return await start_sshx(request)
@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_installing:
return {"status": "installing", "link": None}
if _is_alive() and not _sshx_link:
return {"status": "starting", "link": None, "pid": _sshx_process.pid}
return {"status": "stopped", "link": None}
@router.post("/stop")
async def stop_sshx():
_kill_sshx()
return {"status": "stopped"}
@router.get("/stop")
async def stop_sshx_get():
_kill_sshx()
return {"status": "stopped"}
# compat: old ttyd endpoint still works but now maps to sshx
@router.get("/restart-dashboard")
async def restart_dashboard():
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
@router.post("/restart-dashboard")
async def restart_dashboard_post():
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}