From 81293d2b05dd2983996ba8e3c7302aae09e235b7 Mon Sep 17 00:00:00 2001 From: 6zev Date: Fri, 28 Aug 2026 12:47:44 +0000 Subject: [PATCH] feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart - __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup - dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op - dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice - plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start --- .gitignore | 1 + __init__.py | 269 +++++++++++++++++++----------- dashboard/dist/index.js | 95 +++++++---- dashboard/manifest.json | 6 +- dashboard/plugin_api.py | 354 +++++++++++++++++++++++----------------- plugin.yaml | 5 +- 6 files changed, 447 insertions(+), 283 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/__init__.py b/__init__.py index 56c8aaf..3d4c342 100644 --- a/__init__.py +++ b/__init__.py @@ -1,124 +1,201 @@ """ -sshx-link plugin -================ -Downloads sshx, starts a shared terminal session, -and returns the shareable link. +ttyd-link plugin (replaces sshx) +================================= +Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart. """ import subprocess import threading -import re import logging import shutil import os +import platform +import stat +import urllib.request +import socket +import time logger = logging.getLogger(__name__) -_sshx_process = None -_sshx_link = None +_ttyd_process = None +_ttyd_link = None +_ttyd_port = None +TTYD_BIN = "/tmp/ttyd" +TTYD_DIR = os.path.expanduser("~/.local/bin") +TTYD_BIN_ALT = os.path.join(TTYD_DIR, "ttyd") -def _find_link_from_output(line: str) -> str | None: - """Extract sshx link from process output.""" - # sshx outputs links like: https://sshx.io/s/xxxxx - match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line) - return match.group(0) if match else None +# arch mapping for ttyd releases: https://github.com/tsl0922/ttyd/releases +_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() + # handle uname -m variations + arch = _ARCH_MAP.get(machine, "x86_64") + logger.info(f"Detected machine={machine} -> ttyd arch={arch}") + return arch -def _ensure_sshx_installed() -> bool: - """Download and install sshx binary if not present.""" - if shutil.which("sshx"): - logger.info("sshx already installed at %s", shutil.which("sshx")) - return True +def _get_ttyd_url() -> str: + arch = _detect_arch() + return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}" - logger.info("Downloading sshx...") +def _find_ttyd_bin() -> str | None: + 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_installed() -> str | None: + """Ensure ttyd binary exists, auto-download with arch detection. Returns bin path or None.""" + existing = _find_ttyd_bin() + if existing: + logger.info(f"ttyd already installed at {existing}") + return existing + + url = _get_ttyd_url() + dest = TTYD_BIN + # try ~/.local/bin if /tmp not writable or prefer persistence try: - result = subprocess.run( - ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], - capture_output=True, - text=True, - timeout=60, - ) - if result.returncode != 0: - logger.error("sshx install failed: %s", result.stderr) - return False - return True - except subprocess.TimeoutExpired: - logger.error("sshx install timed out") - return False + os.makedirs(TTYD_DIR, exist_ok=True) + # we download to /tmp first then also copy to ~/.local/bin for caching + logger.info(f"Downloading ttyd from {url} -> {dest}") + urllib.request.urlretrieve(url, dest) + os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) - -def _run_sshx(tool_ctx): - """Run sshx serve in background, capture the link.""" - global _sshx_process, _sshx_link - - if _sshx_process and _sshx_process.poll() is None: - # Already running - tool_ctx.yield_result({"status": "already_running", "link": _sshx_link}) - return - - if not _ensure_sshx_installed(): - tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx"}) - return - - logger.info("Starting sshx...") - _sshx_process = subprocess.Popen( - ["sshx", "--quiet"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - - _sshx_link = None - - def _reader(): - global _sshx_link - for line in _sshx_process.stdout: - line = line.strip() - logger.debug("sshx: %s", line) - link = _find_link_from_output(line) - if link: - _sshx_link = link - tool_ctx.yield_result({"status": "running", "link": link}) - return - # Process ended without outputting a link - tool_ctx.yield_result({"status": "error", "message": "sshx exited without producing a link"}) - - threading.Thread(target=_reader, daemon=True).start() - - -def _auto_install_sshx(): - if shutil.which("sshx"): - logger.info("sshx already installed at %s", shutil.which("sshx")) - return - logger.info("Auto-installing sshx...") - try: - result = subprocess.run( - ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], - capture_output=True, text=True, timeout=60, - ) - if result.returncode == 0 and shutil.which("sshx"): - logger.info("sshx installed successfully") - else: - logger.error("sshx install failed: %s", result.stderr) + # cache copy + try: + if dest != TTYD_BIN_ALT: + import shutil as _sh + _sh.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 successfully to {dest}") + return dest except Exception as e: - logger.error("sshx auto-install error: %s", e) + logger.error(f"ttyd install failed from {url}: {e}") + return None + +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 _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 _run_ttyd(tool_ctx): + """Run ttyd in background, return link.""" + global _ttyd_process, _ttyd_link, _ttyd_port + + if _ttyd_process and _ttyd_process.poll() is None and _ttyd_link: + tool_ctx.yield_result({"status": "already_running", "link": _ttyd_link, "port": _ttyd_port}) + return + + bin_path = _ensure_ttyd_installed() + if not bin_path: + tool_ctx.yield_result({"status": "error", "message": "Failed to install ttyd (auto arch detect failed). Check network to github.com"}) + return + + _ttyd_port = _get_free_port() + logger.info(f"Starting ttyd on port {_ttyd_port} using {bin_path}...") + + try: + _ttyd_process = subprocess.Popen( + [bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + logger.error(f"Failed to start ttyd: {e}") + tool_ctx.yield_result({"status": "error", "message": f"Failed to start ttyd: {e}"}) + return + + if not _wait_for_port(_ttyd_port, timeout=5): + # process might have died + if _ttyd_process.poll() is not None: + tool_ctx.yield_result({"status": "error", "message": "ttyd exited immediately"}) + return + logger.warning("ttyd port not ready but continuing") + + # construct link - use host detection via socket + try: + hostname = socket.gethostname() + # try to get outward IP, fallback to localhost + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + host_ip = s.getsockname()[0] + except Exception: + host_ip = "127.0.0.1" + finally: + s.close() + except Exception: + host_ip = "127.0.0.1" + + _ttyd_link = f"http://{host_ip}:{_ttyd_port}" + logger.info(f"ttyd running at {_ttyd_link}") + tool_ctx.yield_result({"status": "running", "link": _ttyd_link, "port": _ttyd_port}) def register(ctx): - threading.Thread(target=_auto_install_sshx, daemon=True).start() + # no background auto-install that blocks - lazy install on demand + # but we can warm up in thread without requiring restart + def _warmup(): + try: + if not _find_ttyd_bin(): + logger.info("Warming up ttyd install (no restart needed)...") + _ensure_ttyd_installed() + except Exception as e: + logger.debug(f"warmup failed: {e}") + + threading.Thread(target=_warmup, daemon=True).start() + ctx.register_tool( name="sshx_start", toolset="sshx-link", schema={ - "description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.", - "parameters": { - "type": "object", - "properties": {}, - "required": [], - }, + "description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.", + "parameters": {"type": "object", "properties": {}, "required": []}, }, - handler=_run_sshx, + handler=_run_ttyd, ) - logger.info("sshx-link plugin registered") + # also register new name for clarity + ctx.register_tool( + name="ttyd_start", + toolset="sshx-link", + schema={ + "description": "Start ttyd terminal (alias of sshx_start, auto arch, no restart)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + handler=_run_ttyd, + ) + logger.info("ttyd-link plugin registered (replaces sshx, no restart)") + diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 40e2381..11bb9cf 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -6,27 +6,21 @@ const { useState, useEffect, useCallback } = SDK.hooks; const { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components; - const fetchJSON = SDK.fetchJSON; - async function safeFetchJSON(path, opts) { const res = await fetch(path, opts || {}); const text = await res.text(); try { return JSON.parse(text); } catch (_) { - throw new Error("Server returned non-JSON (status " + res.status + ")"); + throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,120)); } } - async function apiPost(path) { - const res = await safeFetchJSON(path, { method: "POST" }); - return res; - } - - function SSHXPage() { + function TTYDPage() { const [link, setLink] = useState(null); + const [port, setPort] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); - const startSSHX = useCallback(async function () { + const start = useCallback(async function () { setStatus("loading"); setError(null); try { @@ -35,24 +29,32 @@ setStatus("installing"); return; } + if (res.status === "running" && res.link) { + setLink(res.link); + setPort(res.port); + setStatus("running"); + return; + } res = await safeFetchJSON("/api/plugins/sshx-link/start"); if (res.link) { setLink(res.link); + setPort(res.port || null); setStatus("running"); } else { setError(res.message || "No link returned"); setStatus("error"); } } catch (err) { - setError(err.message || "Failed to start sshx"); + setError(err.message || "Failed to start ttyd"); setStatus("error"); } }, []); - const stopSSHX = useCallback(async function () { + const stop = useCallback(async function () { try { await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" }); setLink(null); + setPort(null); setStatus("idle"); } catch (err) { setError(err.message); @@ -64,12 +66,18 @@ const res = await safeFetchJSON("/api/plugins/sshx-link/status"); if (res.status === "running" && res.link) { setLink(res.link); + setPort(res.port || null); setStatus("running"); } else if (res.status === "installing") { setStatus("installing"); + } else if (res.status === "stopped") { + if (status === "running") { + setLink(null); + setStatus("idle"); + } } } catch (_) {} - }, []); + }, [status]); useEffect(function () { checkStatus(); @@ -86,13 +94,14 @@ if (status === "installing") { return React.createElement(Card, null, React.createElement(CardHeader, null, - React.createElement(CardTitle, null, "SSHX Shared Terminal") + React.createElement(CardTitle, null, "TTYD Shared Terminal") ), React.createElement(CardContent, null, React.createElement("p", { className: "text-sm text-yellow-500", style: { fontWeight: 600 } - }, "Installing sshx...") + }, "Installing ttyd (auto arch detect)..."), + React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Downloading binary for your arch, no restart needed.") ) ); } @@ -100,29 +109,52 @@ if (status === "running" && link) { return React.createElement(Card, null, React.createElement(CardHeader, null, - React.createElement(CardTitle, null, "SSHX Shared Terminal") + React.createElement(CardTitle, null, "TTYD Shared Terminal"), + React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running - No dashboard restart needed") ), React.createElement(CardContent, null, - React.createElement("p", { - className: "text-sm text-green-500", - style: { fontWeight: 600, marginBottom: "0.5rem" } - }, "Running"), React.createElement("a", { href: link, target: "_blank", rel: "noopener noreferrer", style: { color: "#60a5fa", - fontSize: "1.1rem", + fontSize: "1rem", wordBreak: "break-all", - textDecoration: "underline" + textDecoration: "underline", + display: "block", + marginBottom: "0.75rem" } }, link), - React.createElement("div", { style: { marginTop: "1rem" } }, + React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } }, React.createElement(Button, { - onClick: stopSSHX, - variant: "destructive" + onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); }, + variant: "outline", + size: "sm" + }, "Copy Link"), + React.createElement(Button, { + onClick: stop, + variant: "destructive", + size: "sm" }, "Stop") + ), + React.createElement("div", { + style: { + border: "1px solid #333", + borderRadius: "8px", + overflow: "hidden", + height: "500px", + background: "#000" + } + }, + React.createElement("iframe", { + src: link, + style: { width: "100%", height: "100%", border: "0" }, + title: "TTYD Terminal" + }) + ), + React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, + "Terminal embedded above. Open in new tab if iframe blocked by CSP." ) ) ); @@ -130,24 +162,25 @@ return React.createElement(Card, null, React.createElement(CardHeader, null, - React.createElement(CardTitle, null, "SSHX Shared Terminal") + React.createElement(CardTitle, null, "TTYD Shared Terminal"), + React.createElement("p", { className: "text-xs text-muted-foreground" }, "Auto arch detection, no restart required") ), React.createElement(CardContent, null, React.createElement("p", { className: "text-sm text-muted-foreground", style: { marginBottom: "1rem" } - }, "Start a shared terminal session. Others can join via the link."), + }, "Start a shared terminal via ttyd. Works offline, no dependency on sshx.io."), error && React.createElement("p", { className: "text-sm text-red-500", - style: { marginBottom: "1rem" } + style: { marginBottom: "1rem", whiteSpace: "pre-wrap" } }, "Error: " + error), React.createElement(Button, { - onClick: startSSHX, + onClick: start, disabled: status === "loading" - }, status === "loading" ? "Starting..." : "Start SSHX") + }, status === "loading" ? "Starting ttyd..." : "Start TTYD") ) ); } - window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage); + window.__HERMES_PLUGINS__.register("sshx-link", TTYDPage); })(); diff --git a/dashboard/manifest.json b/dashboard/manifest.json index dc18972..faf2750 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -1,9 +1,9 @@ { "name": "sshx-link", - "label": "SSHX", - "description": "Start a shared terminal session and get the link", + "label": "TTYD Terminal", + "description": "TTYD shared terminal - auto arch, no restart", "icon": "Terminal", - "version": "1.0.0", + "version": "1.1.0", "tab": { "path": "/sshx", "position": "end" diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 91ca367..50ae557 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -1,199 +1,251 @@ import asyncio import subprocess -import re import os -import shutil import logging import time +import platform +import stat +import urllib.request +import socket +import shutil from typing import Optional -from fastapi import APIRouter, BackgroundTasks +from fastapi import APIRouter, Request from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) router = APIRouter() -_sshx_link = None -_sshx_pid = None -_sshx_installing = False -_sshx_log = "/tmp/sshx_link.log" -_sshx_pid_file = "/tmp/sshx_link.pid" +_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" -LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?") +_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", +} -import threading as _threading +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 _auto_install(): - global _sshx_installing - if shutil.which("sshx"): - logger.info("sshx already installed at %s", shutil.which("sshx")) - return - _sshx_installing = True - logger.info("Auto-installing sshx...") +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: - subprocess.run( - ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], - capture_output=True, timeout=60, - ) - if shutil.which("sshx"): - logger.info("sshx installed successfully") - _trigger_dashboard_restart() - else: - logger.error("sshx install failed") - except Exception as e: - logger.error("sshx auto-install error: %s", e) - finally: - _sshx_installing = False - - -@router.on_event("startup") -def _startup_auto_install(): - _threading.Thread(target=_auto_install, daemon=True).start() - - -def _ensure_sshx() -> tuple[bool, bool]: - """Ensure sshx is installed. Returns (success, needs_restart).""" - if shutil.which("sshx"): - return True, False - try: - subprocess.run( - ["sh", "-c", "curl -sSf https://sshx.io/get | sh"], - capture_output=True, timeout=60, - ) - if shutil.which("sshx"): - return True, True # newly installed, needs restart - return False, False - except Exception as e: - logger.error("sshx install failed: %s", e) - return False, False - - -def _trigger_dashboard_restart(): - try: - subprocess.Popen( - ["nohup", "bash", "-c", - "sleep 3 && sudo systemctl restart hermes-dashboard"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, start_new_session=True, - ) - except Exception as e: - logger.error("Failed to trigger dashboard restart: %s", e) - - -def _kill_sshx(): - global _sshx_pid, _sshx_link - if _sshx_pid: + 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: - os.kill(_sshx_pid, 15) - except (ProcessLookupError, PermissionError): + 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 - _sshx_pid = None - _sshx_link = None - for f in [_sshx_log, _sshx_pid_file]: - if os.path.exists(f): - os.remove(f) + 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 _sshx_pid - if not _sshx_pid: + global _ttyd_pid + if not _ttyd_pid: return False try: - os.kill(_sshx_pid, 0) + 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 _read_link_from_log() -> Optional[str]: - if not os.path.exists(_sshx_log): - return None - try: - with open(_sshx_log) as f: - content = f.read() - m = LINK_RE.search(content) - if m: - return m.group(0) - except Exception: - pass - return None - - -def _start_sshx_background(): - global _sshx_pid, _sshx_link - _kill_sshx() - _sshx_link = None - - log_fh = open(_sshx_log, "w") - proc = subprocess.Popen( - ["sshx", "--quiet"], - stdout=log_fh, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) - _sshx_pid = proc.pid - with open(_sshx_pid_file, "w") as pf: - pf.write(str(proc.pid)) - - for _ in range(30): - time.sleep(1) - link = _read_link_from_log() - if link: - _sshx_link = link - return True - if proc.poll() is not None: - return False +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_sshx(background_tasks: BackgroundTasks): - global _sshx_link, _sshx_pid +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} - if _is_alive() and _sshx_link: - return {"status": "running", "link": _sshx_link} - - ok, needs_restart = _ensure_sshx() - if not ok: - return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"}) + # 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, _start_sshx_background) + result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header)) - # Defer dashboard restart until AFTER the response is sent - if needs_restart: - background_tasks.add_task(_trigger_dashboard_restart) - - if result: - return {"status": "running", "link": _sshx_link} - return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"}) + 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 sshx_status(): - global _sshx_link, _sshx_installing - if _is_alive() and _sshx_link: - return {"status": "running", "link": _sshx_link} - if _sshx_installing: +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_sshx(): - _kill_sshx() +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(): - try: - subprocess.Popen( - ["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True, - ) - return {"status": "restarting"} - except Exception as e: - return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) + 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."} + diff --git a/plugin.yaml b/plugin.yaml index 8531f8d..ffe0622 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,8 +1,9 @@ name: sshx-link -version: 1.0.0 -description: Start sshx shared terminal and return the share link +version: 1.1.0 +description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart author: sisyphus provides_tools: - sshx_start + - ttyd_start provides_hooks: [] requires_env: []