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 ttyd-link plugin (replaces sshx)
================ =================================
Downloads sshx, starts a shared terminal session, Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart.
and returns the shareable link.
""" """
import subprocess import subprocess
import threading import threading
import re
import logging import logging
import shutil import shutil
import os import os
import platform
import stat
import urllib.request
import socket
import time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_sshx_process = None _ttyd_process = None
_sshx_link = 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: # arch mapping for ttyd releases: https://github.com/tsl0922/ttyd/releases
"""Extract sshx link from process output.""" _ARCH_MAP = {
# sshx outputs links like: https://sshx.io/s/xxxxx "x86_64": "x86_64",
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line) "amd64": "x86_64",
return match.group(0) if match else None "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: def _get_ttyd_url() -> str:
"""Download and install sshx binary if not present.""" arch = _detect_arch()
if shutil.which("sshx"): return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
logger.info("sshx already installed at %s", shutil.which("sshx"))
return True
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: try:
result = subprocess.run( os.makedirs(TTYD_DIR, exist_ok=True)
["sh", "-c", "curl -sSf https://sshx.io/get | sh"], # we download to /tmp first then also copy to ~/.local/bin for caching
capture_output=True, logger.info(f"Downloading ttyd from {url} -> {dest}")
text=True, urllib.request.urlretrieve(url, dest)
timeout=60, os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
)
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
# cache copy
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: try:
result = subprocess.run( if dest != TTYD_BIN_ALT:
["sh", "-c", "curl -sSf https://sshx.io/get | sh"], import shutil as _sh
capture_output=True, text=True, timeout=60, _sh.copy2(dest, TTYD_BIN_ALT)
) os.chmod(TTYD_BIN_ALT, os.stat(TTYD_BIN_ALT).st_mode | stat.S_IEXEC)
if result.returncode == 0 and shutil.which("sshx"): except Exception:
logger.info("sshx installed successfully") pass
else: logger.info(f"ttyd installed successfully to {dest}")
logger.error("sshx install failed: %s", result.stderr) return dest
except Exception as e: 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): 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( ctx.register_tool(
name="sshx_start", name="sshx_start",
toolset="sshx-link", toolset="sshx-link",
schema={ schema={
"description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.", "description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.",
"parameters": { "parameters": {"type": "object", "properties": {}, "required": []},
"type": "object",
"properties": {},
"required": [],
}, },
}, handler=_run_ttyd,
handler=_run_sshx,
) )
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 { useState, useEffect, useCallback } = SDK.hooks;
const { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components; const { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components;
const fetchJSON = SDK.fetchJSON;
async function safeFetchJSON(path, opts) { async function safeFetchJSON(path, opts) {
const res = await fetch(path, opts || {}); const res = await fetch(path, opts || {});
const text = await res.text(); const text = await res.text();
try { return JSON.parse(text); } catch (_) { 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) { function TTYDPage() {
const res = await safeFetchJSON(path, { method: "POST" });
return res;
}
function SSHXPage() {
const [link, setLink] = useState(null); const [link, setLink] = useState(null);
const [port, setPort] = useState(null);
const [status, setStatus] = useState("idle"); const [status, setStatus] = useState("idle");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const startSSHX = useCallback(async function () { const start = useCallback(async function () {
setStatus("loading"); setStatus("loading");
setError(null); setError(null);
try { try {
@ -35,24 +29,32 @@
setStatus("installing"); setStatus("installing");
return; return;
} }
if (res.status === "running" && res.link) {
setLink(res.link);
setPort(res.port);
setStatus("running");
return;
}
res = await safeFetchJSON("/api/plugins/sshx-link/start"); res = await safeFetchJSON("/api/plugins/sshx-link/start");
if (res.link) { if (res.link) {
setLink(res.link); setLink(res.link);
setPort(res.port || null);
setStatus("running"); setStatus("running");
} else { } else {
setError(res.message || "No link returned"); setError(res.message || "No link returned");
setStatus("error"); setStatus("error");
} }
} catch (err) { } catch (err) {
setError(err.message || "Failed to start sshx"); setError(err.message || "Failed to start ttyd");
setStatus("error"); setStatus("error");
} }
}, []); }, []);
const stopSSHX = useCallback(async function () { const stop = useCallback(async function () {
try { try {
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" }); await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
setLink(null); setLink(null);
setPort(null);
setStatus("idle"); setStatus("idle");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@ -64,12 +66,18 @@
const res = await safeFetchJSON("/api/plugins/sshx-link/status"); const res = await safeFetchJSON("/api/plugins/sshx-link/status");
if (res.status === "running" && res.link) { if (res.status === "running" && res.link) {
setLink(res.link); setLink(res.link);
setPort(res.port || null);
setStatus("running"); setStatus("running");
} else if (res.status === "installing") { } else if (res.status === "installing") {
setStatus("installing"); setStatus("installing");
} else if (res.status === "stopped") {
if (status === "running") {
setLink(null);
setStatus("idle");
}
} }
} catch (_) {} } catch (_) {}
}, []); }, [status]);
useEffect(function () { useEffect(function () {
checkStatus(); checkStatus();
@ -86,13 +94,14 @@
if (status === "installing") { if (status === "installing") {
return React.createElement(Card, null, return React.createElement(Card, null,
React.createElement(CardHeader, 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(CardContent, null,
React.createElement("p", { React.createElement("p", {
className: "text-sm text-yellow-500", className: "text-sm text-yellow-500",
style: { fontWeight: 600 } 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) { if (status === "running" && link) {
return React.createElement(Card, null, return React.createElement(Card, null,
React.createElement(CardHeader, 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(CardContent, null,
React.createElement("p", {
className: "text-sm text-green-500",
style: { fontWeight: 600, marginBottom: "0.5rem" }
}, "Running"),
React.createElement("a", { React.createElement("a", {
href: link, href: link,
target: "_blank", target: "_blank",
rel: "noopener noreferrer", rel: "noopener noreferrer",
style: { style: {
color: "#60a5fa", color: "#60a5fa",
fontSize: "1.1rem", fontSize: "1rem",
wordBreak: "break-all", wordBreak: "break-all",
textDecoration: "underline" textDecoration: "underline",
display: "block",
marginBottom: "0.75rem"
} }
}, link), }, link),
React.createElement("div", { style: { marginTop: "1rem" } }, React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } },
React.createElement(Button, { React.createElement(Button, {
onClick: stopSSHX, onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
variant: "destructive" variant: "outline",
size: "sm"
}, "Copy Link"),
React.createElement(Button, {
onClick: stop,
variant: "destructive",
size: "sm"
}, "Stop") }, "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, return React.createElement(Card, null,
React.createElement(CardHeader, 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(CardContent, null,
React.createElement("p", { React.createElement("p", {
className: "text-sm text-muted-foreground", className: "text-sm text-muted-foreground",
style: { marginBottom: "1rem" } 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", { error && React.createElement("p", {
className: "text-sm text-red-500", className: "text-sm text-red-500",
style: { marginBottom: "1rem" } style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
}, "Error: " + error), }, "Error: " + error),
React.createElement(Button, { React.createElement(Button, {
onClick: startSSHX, onClick: start,
disabled: status === "loading" 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", "name": "sshx-link",
"label": "SSHX", "label": "TTYD Terminal",
"description": "Start a shared terminal session and get the link", "description": "TTYD shared terminal - auto arch, no restart",
"icon": "Terminal", "icon": "Terminal",
"version": "1.0.0", "version": "1.1.0",
"tab": { "tab": {
"path": "/sshx", "path": "/sshx",
"position": "end" "position": "end"

View File

@ -1,199 +1,251 @@
import asyncio import asyncio
import subprocess import subprocess
import re
import os import os
import shutil
import logging import logging
import time import time
import platform
import stat
import urllib.request
import socket
import shutil
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_sshx_link = None _ttyd_link = None
_sshx_pid = None _ttyd_pid = None
_sshx_installing = False _ttyd_port = None
_sshx_log = "/tmp/sshx_link.log" _ttyd_installing = False
_sshx_pid_file = "/tmp/sshx_link.pid" _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(): def _find_ttyd_bin() -> Optional[str]:
global _sshx_installing for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]:
if shutil.which("sshx"): if p and os.path.exists(p) and os.access(p, os.X_OK):
logger.info("sshx already installed at %s", shutil.which("sshx")) return p
return return None
_sshx_installing = True
logger.info("Auto-installing sshx...") 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: try:
subprocess.run( url = _get_ttyd_url()
["sh", "-c", "curl -sSf https://sshx.io/get | sh"], dest = _ttyd_bin
capture_output=True, timeout=60, os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True)
) logger.info(f"Downloading ttyd {url} -> {dest}")
if shutil.which("sshx"): # urllib no curl|sh, no dashboard restart needed
logger.info("sshx installed successfully") urllib.request.urlretrieve(url, dest)
_trigger_dashboard_restart() os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
else: # cache
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: try:
subprocess.run( if dest != _ttyd_bin_alt:
["sh", "-c", "curl -sSf https://sshx.io/get | sh"], shutil.copy2(dest, _ttyd_bin_alt)
capture_output=True, timeout=60, os.chmod(_ttyd_bin_alt, os.stat(_ttyd_bin_alt).st_mode | stat.S_IEXEC)
) except Exception:
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:
try:
os.kill(_sshx_pid, 15)
except (ProcessLookupError, PermissionError):
pass pass
_sshx_pid = None logger.info(f"ttyd installed to {dest}")
_sshx_link = None return dest
for f in [_sshx_log, _sshx_pid_file]: except Exception as e:
if os.path.exists(f): logger.error(f"ttyd install failed: {e}")
os.remove(f) 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: def _is_alive() -> bool:
global _sshx_pid global _ttyd_pid
if not _sshx_pid: if not _ttyd_pid:
return False return False
try: try:
os.kill(_sshx_pid, 0) os.kill(_ttyd_pid, 0)
return True return True
except (ProcessLookupError, PermissionError): except (ProcessLookupError, PermissionError):
return False return False
def _kill_ttyd():
def _read_link_from_log() -> Optional[str]: global _ttyd_pid, _ttyd_link, _ttyd_port
if not os.path.exists(_sshx_log): if _ttyd_pid:
return None
try: try:
with open(_sshx_log) as f: os.kill(_ttyd_pid, 15)
content = f.read() time.sleep(0.3)
m = LINK_RE.search(content) # force kill if still alive
if m: try:
return m.group(0) 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: except Exception:
pass pass
return None
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_sshx_background(): def _start_ttyd_background(host_header: str = "") -> bool:
global _sshx_pid, _sshx_link global _ttyd_pid, _ttyd_link, _ttyd_port
_kill_sshx() _kill_ttyd()
_sshx_link = None
log_fh = open(_sshx_log, "w") 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( proc = subprocess.Popen(
["sshx", "--quiet"], [bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
stdout=log_fh, stdout=open(_ttyd_log, "w"),
stderr=subprocess.DEVNULL, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
start_new_session=True, start_new_session=True,
) )
_sshx_pid = proc.pid _ttyd_pid = proc.pid
with open(_sshx_pid_file, "w") as pf: # wait for listening
pf.write(str(proc.pid)) if not _wait_for_port(_ttyd_port, timeout=5):
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: if proc.poll() is not None:
logger.error(f"ttyd exited quickly code={proc.poll()}")
return False return False
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") @router.get("/start")
async def start_sshx(background_tasks: BackgroundTasks): async def start_ttyd(request: Request):
global _sshx_link, _sshx_pid global _ttyd_link, _ttyd_pid, _ttyd_port
if _is_alive() and _ttyd_link:
if _is_alive() and _sshx_link: return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
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() loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _start_sshx_background) 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"})
# Defer dashboard restart until AFTER the response is sent host_header = request.headers.get("host", "")
if needs_restart: loop = asyncio.get_event_loop()
background_tasks.add_task(_trigger_dashboard_restart) result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header))
if result: if result and _ttyd_link:
return {"status": "running", "link": _sshx_link} return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"}) 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") @router.get("/status")
async def sshx_status(): async def ttyd_status():
global _sshx_link, _sshx_installing global _ttyd_link, _ttyd_installing, _ttyd_port
if _is_alive() and _sshx_link: if _is_alive() and _ttyd_link:
return {"status": "running", "link": _sshx_link} return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
if _sshx_installing: if _ttyd_installing:
return {"status": "installing", "link": None} return {"status": "installing", "link": None}
return {"status": "stopped", "link": None} return {"status": "stopped", "link": None}
@router.post("/stop") @router.post("/stop")
async def stop_sshx(): async def stop_ttyd():
_kill_sshx() _kill_ttyd()
return {"status": "stopped"} 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") @router.post("/restart-dashboard")
async def restart_dashboard(): async def restart_dashboard():
try: return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
subprocess.Popen(
["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"], @router.get("/restart-dashboard")
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, async def restart_dashboard_get():
start_new_session=True, return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
)
return {"status": "restarting"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})

View File

@ -1,8 +1,9 @@
name: sshx-link name: sshx-link
version: 1.0.0 version: 1.1.0
description: Start sshx shared terminal and return the share link description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart
author: sisyphus author: sisyphus
provides_tools: provides_tools:
- sshx_start - sshx_start
- ttyd_start
provides_hooks: [] provides_hooks: []
requires_env: [] requires_env: []