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."}