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
This commit is contained in:
6zev 2026-08-28 12:47:44 +00:00
parent ffece76498
commit 81293d2b05
6 changed files with 447 additions and 283 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

View File

@ -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)")

View File

@ -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);
})();

View File

@ -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"

View File

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

View File

@ -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: []