hermes-sshx-link/dashboard/plugin_api.py

252 lines
7.8 KiB
Python

import asyncio
import subprocess
import os
import logging
import time
import platform
import stat
import urllib.request
import socket
import shutil
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
_ttyd_link = None
_ttyd_pid = None
_ttyd_port = None
_ttyd_installing = False
_ttyd_bin = "/tmp/ttyd"
_ttyd_bin_alt = os.path.expanduser("~/.local/bin/ttyd")
_ttyd_log = "/tmp/ttyd_link.log"
_ARCH_MAP = {
"x86_64": "x86_64",
"amd64": "x86_64",
"aarch64": "aarch64",
"arm64": "aarch64",
"armv8l": "aarch64",
"armv7l": "arm",
"armv6l": "arm",
"arm": "arm",
"armhf": "armhf",
"i386": "i686",
"i686": "i686",
"mips": "mips",
"mips64": "mips64",
"mips64el": "mips64el",
"mipsel": "mipsel",
"s390x": "s390x",
}
def _detect_arch() -> str:
machine = platform.machine().lower()
arch = _ARCH_MAP.get(machine, "x86_64")
logger.info(f"Detected arch machine={machine} -> ttyd arch={arch}")
return arch
def _get_ttyd_url() -> str:
arch = _detect_arch()
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
def _find_ttyd_bin() -> Optional[str]:
for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]:
if p and os.path.exists(p) and os.access(p, os.X_OK):
return p
return None
def _ensure_ttyd() -> Optional[str]:
"""Ensure ttyd exists, download with arch detection if needed. Returns bin path."""
existing = _find_ttyd_bin()
if existing:
return existing
global _ttyd_installing
_ttyd_installing = True
try:
url = _get_ttyd_url()
dest = _ttyd_bin
os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True)
logger.info(f"Downloading ttyd {url} -> {dest}")
# urllib no curl|sh, no dashboard restart needed
urllib.request.urlretrieve(url, dest)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
# cache
try:
if dest != _ttyd_bin_alt:
shutil.copy2(dest, _ttyd_bin_alt)
os.chmod(_ttyd_bin_alt, os.stat(_ttyd_bin_alt).st_mode | stat.S_IEXEC)
except Exception:
pass
logger.info(f"ttyd installed to {dest}")
return dest
except Exception as e:
logger.error(f"ttyd install failed: {e}")
return None
finally:
_ttyd_installing = False
def _get_free_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
port = s.getsockname()[1]
s.close()
return port
def _is_alive() -> bool:
global _ttyd_pid
if not _ttyd_pid:
return False
try:
os.kill(_ttyd_pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
def _kill_ttyd():
global _ttyd_pid, _ttyd_link, _ttyd_port
if _ttyd_pid:
try:
os.kill(_ttyd_pid, 15)
time.sleep(0.3)
# force kill if still alive
try:
os.kill(_ttyd_pid, 0)
os.kill(_ttyd_pid, 9)
except ProcessLookupError:
pass
except (ProcessLookupError, PermissionError):
pass
_ttyd_pid = None
_ttyd_link = None
_ttyd_port = None
if os.path.exists(_ttyd_log):
try:
os.remove(_ttyd_log)
except Exception:
pass
def _wait_for_port(port: int, timeout: int = 5) -> bool:
for _ in range(timeout * 10):
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return True
except OSError:
time.sleep(0.1)
return False
def _start_ttyd_background(host_header: str = "") -> bool:
global _ttyd_pid, _ttyd_link, _ttyd_port
_kill_ttyd()
bin_path = _ensure_ttyd()
if not bin_path:
return False
_ttyd_port = _get_free_port()
# derive host for link: use Host header if available, else local IP
host_ip = "127.0.0.1"
if host_header:
# Host header may be "dashboard.example.com:8000" -> take hostname
host_ip = host_header.split(":")[0]
# if host is localhost, try to get outward IP for useful link
if host_ip in ("localhost", "127.0.0.1"):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
host_ip = s.getsockname()[0]
s.close()
except Exception:
host_ip = "127.0.0.1"
else:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
host_ip = s.getsockname()[0]
s.close()
except Exception:
pass
logger.info(f"Starting ttyd {bin_path} on port {_ttyd_port} (host {host_ip})")
try:
proc = subprocess.Popen(
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
stdout=open(_ttyd_log, "w"),
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
_ttyd_pid = proc.pid
# wait for listening
if not _wait_for_port(_ttyd_port, timeout=5):
if proc.poll() is not None:
logger.error(f"ttyd exited quickly code={proc.poll()}")
return False
logger.warning("ttyd port not ready but proc alive, continuing")
_ttyd_link = f"http://{host_ip}:{_ttyd_port}"
logger.info(f"ttyd running link={_ttyd_link} pid={_ttyd_pid}")
return True
except Exception as e:
logger.error(f"Failed to start ttyd: {e}")
return False
@router.get("/start")
async def start_ttyd(request: Request):
global _ttyd_link, _ttyd_pid, _ttyd_port
if _is_alive() and _ttyd_link:
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
# install check (no restart needed)
bin_path = _find_ttyd_bin()
if not bin_path:
# try install synchronously but non-blocking via executor
loop = asyncio.get_event_loop()
bin_path = await loop.run_in_executor(None, _ensure_ttyd)
if not bin_path:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install ttyd (arch auto-detect). Check network to github.com"})
host_header = request.headers.get("host", "")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header))
if result and _ttyd_link:
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
return JSONResponse(status_code=504, content={"status": "error", "message": "ttyd did not start in time"})
# keep POST alias for backward compat with old frontend
@router.post("/start")
async def start_ttyd_post(request: Request):
return await start_ttyd(request)
@router.get("/status")
async def ttyd_status():
global _ttyd_link, _ttyd_installing, _ttyd_port
if _is_alive() and _ttyd_link:
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
if _ttyd_installing:
return {"status": "installing", "link": None}
return {"status": "stopped", "link": None}
@router.post("/stop")
async def stop_ttyd():
_kill_ttyd()
return {"status": "stopped"}
@router.get("/stop")
async def stop_ttyd_get():
_kill_ttyd()
return {"status": "stopped"}
# deprecated - no restart needed anymore, keep for compat but do nothing
@router.post("/restart-dashboard")
async def restart_dashboard():
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
@router.get("/restart-dashboard")
async def restart_dashboard_get():
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}