252 lines
7.8 KiB
Python
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."}
|
|
|