feat: sshx.io shell plugin v2.0.0 - create shell at sshx.io and return link

- Auto arch detection x86_64/aarch64/armv6/armv7 via S3 https://s3.amazonaws.com/sshx/sshx-${arch}${suffix}.tar.gz
- Download+extract with AppleDouble ._ skip, chmod +x to /tmp/sshx + ~/.local/bin/sshx
- Run sshx --quiet, parse https://sshx.io/s/...#... link via select+regex
- Dashboard plugin_api: /start /status /stop with run_in_executor, no restart needed
- Frontend: sshx.io Terminal tab at /sshx, Copy/Open/Stop + iframe embed
This commit is contained in:
6zev 2026-09-03 04:21:03 +00:00
parent 81293d2b05
commit 51b27ed109
5 changed files with 511 additions and 322 deletions

View File

@ -1,9 +1,11 @@
""" """
ttyd-link plugin (replaces sshx) sshx-link plugin — creates a shell at sshx.io and returns the shareable link
================================= ============================================================================
Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart. - Auto arch detection (x86_64 / aarch64 / armv6 / armv7)
- Downloads sshx binary from S3 without curl|sh
- Starts `sshx` and parses https://sshx.io/s/... link from stdout
- No dashboard restart needed
""" """
import subprocess import subprocess
import threading import threading
import logging import logging
@ -12,167 +14,281 @@ import os
import platform import platform
import stat import stat
import urllib.request import urllib.request
import socket import re
import tarfile
import tempfile
import time import time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ttyd_process = None _sshx_process = None
_ttyd_link = None _sshx_link = None
_ttyd_port = None _sshx_log = "/tmp/sshx_link.log"
SSHX_BIN = "/tmp/sshx"
SSHX_BIN_ALT = os.path.expanduser("~/.local/bin/sshx")
SSHX_TAR = "/tmp/sshx.tar.gz"
# Reuse ttyd fallback for offline mode (optional)
TTYD_BIN = "/tmp/ttyd" TTYD_BIN = "/tmp/ttyd"
TTYD_DIR = os.path.expanduser("~/.local/bin")
TTYD_BIN_ALT = os.path.join(TTYD_DIR, "ttyd")
# arch mapping for ttyd releases: https://github.com/tsl0922/ttyd/releases LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
_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:
def _detect_sshx_arch():
machine = platform.machine().lower() machine = platform.machine().lower()
# handle uname -m variations system = platform.system().lower()
arch = _ARCH_MAP.get(machine, "x86_64") if system == "darwin":
logger.info(f"Detected machine={machine} -> ttyd arch={arch}") suffix = "-apple-darwin"
return arch if machine in ("aarch64", "arm64", "armv8l", "armv8b"):
arch = "aarch64"
elif machine in ("x86_64", "x64", "amd64"):
arch = "x86_64"
else:
arch = "aarch64" if "arm" in machine else "x86_64"
return arch, suffix
# default linux
suffix = "-unknown-linux-musl"
if machine in ("aarch64", "aarch64_be", "arm64", "armv8b", "armv8l"):
arch = "aarch64"
elif machine in ("x86_64", "x64", "amd64"):
arch = "x86_64"
elif machine == "armv6l":
arch = "arm"
suffix += "eabihf"
elif machine == "armv7l":
arch = "armv7"
suffix += "eabihf"
else:
arch = "x86_64"
return arch, suffix
def _get_ttyd_url() -> str:
arch = _detect_arch()
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
def _find_ttyd_bin() -> str | None: def _get_sshx_url() -> str:
for p in [TTYD_BIN, TTYD_BIN_ALT, shutil.which("ttyd")]: arch, suffix = _detect_sshx_arch()
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
logger.info(f"Detected arch={arch} suffix={suffix} -> sshx url={url}")
return url
def _find_sshx_bin() -> str | None:
for p in [SSHX_BIN, SSHX_BIN_ALT, shutil.which("sshx")]:
if p and os.path.exists(p) and os.access(p, os.X_OK): if p and os.path.exists(p) and os.access(p, os.X_OK):
return p return p
return None return None
def _ensure_ttyd_installed() -> str | None:
"""Ensure ttyd binary exists, auto-download with arch detection. Returns bin path or None.""" def _ensure_sshx_installed() -> str | None:
existing = _find_ttyd_bin() """Ensure sshx binary exists, download + extract if needed. Returns path or None."""
existing = _find_sshx_bin()
if existing: if existing:
logger.info(f"ttyd already installed at {existing}") logger.info(f"sshx already installed at {existing}")
return existing return existing
url = _get_ttyd_url() url = _get_sshx_url()
dest = TTYD_BIN dest = SSHX_BIN
# try ~/.local/bin if /tmp not writable or prefer persistence os.makedirs(os.path.dirname(SSHX_BIN_ALT), exist_ok=True)
tmp_tar = SSHX_TAR
try: try:
os.makedirs(TTYD_DIR, exist_ok=True) logger.info(f"Downloading sshx from {url} -> {tmp_tar}")
# we download to /tmp first then also copy to ~/.local/bin for caching urllib.request.urlretrieve(url, tmp_tar)
logger.info(f"Downloading ttyd from {url} -> {dest}") # extract tar.gz - contains single file 'sshx'
urllib.request.urlretrieve(url, dest) logger.info(f"Extracting {tmp_tar}")
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) with tarfile.open(tmp_tar, "r:gz") as tf:
# find member named sshx, skip AppleDouble ._ files
# cache copy member = None
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if base == "sshx" and m.isfile():
member = m
break
if not member:
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if m.isfile():
member = m
break
if not member:
member = tf.getmembers()[0]
# extract to tmp dir then move
tmpdir = tempfile.mkdtemp()
try:
tf.extract(member, path=tmpdir, filter='fully_trusted')
except TypeError:
tf.extract(member, path=tmpdir)
extracted = os.path.join(tmpdir, member.name)
# member.name may contain subdir
if not os.path.exists(extracted):
# try find sshx recursively
for root, _, files in os.walk(tmpdir):
if "sshx" in files:
extracted = os.path.join(root, "sshx")
break
shutil.copy2(extracted, dest)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
# cache copy
try:
if dest != SSHX_BIN_ALT:
shutil.copy2(dest, SSHX_BIN_ALT)
os.chmod(SSHX_BIN_ALT, os.stat(SSHX_BIN_ALT).st_mode | stat.S_IEXEC)
except Exception:
pass
shutil.rmtree(tmpdir, ignore_errors=True)
try: try:
if dest != TTYD_BIN_ALT: os.remove(tmp_tar)
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: except Exception:
pass pass
logger.info(f"ttyd installed successfully to {dest}") logger.info(f"sshx installed successfully to {dest}")
return dest return dest
except Exception as e: except Exception as e:
logger.error(f"ttyd install failed from {url}: {e}") logger.error(f"sshx install failed from {url}: {e}")
return None 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: def _is_sshx_alive() -> bool:
for _ in range(timeout * 10): global _sshx_process
if _sshx_process is None:
return False
return _sshx_process.poll() is None
def _capture_link(proc, timeout=15) -> str | None:
"""Read stdout until link found or timeout. Returns link or None."""
link = None
start = time.time()
# use non-blocking read via thread? simpler: read line with timeout via poll
# We'll read via proc.stdout readline with timeout loop
import select
output = ""
while time.time() - start < timeout:
if proc.poll() is not None:
# process died, collect remaining
try:
rem = proc.stdout.read() or ""
output += rem
except Exception:
pass
break
# check if data available
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=1): # select on stdout fd
return True r, _, _ = select.select([proc.stdout], [], [], 0.5)
except OSError: if r:
time.sleep(0.1) line = proc.stdout.readline()
return False if not line:
time.sleep(0.1)
continue
output += line
logger.info(f"sshx stdout: {line.strip()}")
m = LINK_RE.search(line)
if m:
link = m.group(0)
break
# also check whole output so far (link may span?)
m2 = LINK_RE.search(output)
if m2:
link = m2.group(0)
break
except Exception as e:
logger.debug(f"capture error: {e}")
time.sleep(0.2)
if not link:
m = LINK_RE.search(output)
if m:
link = m.group(0)
# also try reading logfile if we used file
if not link:
logger.warning(f"sshx capture timeout, output so far: {output[:500]}")
return link
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: def _run_sshx(tool_ctx):
tool_ctx.yield_result({"status": "already_running", "link": _ttyd_link, "port": _ttyd_port}) """Start sshx and return link. Reuse if already running."""
global _sshx_process, _sshx_link
if _is_sshx_alive() and _sshx_link:
tool_ctx.yield_result({"status": "already_running", "link": _sshx_link})
return return
bin_path = _ensure_ttyd_installed() bin_path = _ensure_sshx_installed()
if not bin_path: if not bin_path:
tool_ctx.yield_result({"status": "error", "message": "Failed to install ttyd (auto arch detect failed). Check network to github.com"}) tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx. Check network to s3.amazonaws.com/sshx"})
return return
_ttyd_port = _get_free_port() logger.info(f"Starting sshx via {bin_path} ...")
logger.info(f"Starting ttyd on port {_ttyd_port} using {bin_path}...")
try: try:
_ttyd_process = subprocess.Popen( # --quiet prints only the link (https://sshx.io/s/...), far more reliable to parse
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"], # fallback to no-flag if --quiet not supported (old version)
stdout=subprocess.DEVNULL, _sshx_process = subprocess.Popen(
stderr=subprocess.DEVNULL, [bin_path, "--quiet"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
start_new_session=True, start_new_session=True,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to start ttyd: {e}") logger.error(f"Failed to start sshx: {e}")
tool_ctx.yield_result({"status": "error", "message": f"Failed to start ttyd: {e}"}) tool_ctx.yield_result({"status": "error", "message": f"Failed to start sshx: {e}"})
return return
if not _wait_for_port(_ttyd_port, timeout=5): link = _capture_link(_sshx_process, timeout=15)
# process might have died if link:
if _ttyd_process.poll() is not None: _sshx_link = link
tool_ctx.yield_result({"status": "error", "message": "ttyd exited immediately"}) logger.info(f"sshx running at {_sshx_link} pid={_sshx_process.pid}")
return tool_ctx.yield_result({"status": "running", "link": _sshx_link, "pid": _sshx_process.pid})
logger.warning("ttyd port not ready but continuing") else:
# process may still be alive but link not captured — check if died
if _sshx_process.poll() is not None:
try:
out = _sshx_process.stdout.read() or ""
except Exception:
out = ""
tool_ctx.yield_result({"status": "error", "message": f"sshx exited quickly (code={_sshx_process.poll()}): {out[:300]}"})
_sshx_process = None
else:
# still running but no link parsed — return with log hint
tool_ctx.yield_result({"status": "running", "link": None, "message": "sshx started but link not parsed yet, check logs", "pid": _sshx_process.pid})
# construct link - use host detection via socket
try: def _stop_sshx(tool_ctx=None):
hostname = socket.gethostname() global _sshx_process, _sshx_link
# try to get outward IP, fallback to localhost if _sshx_process and _sshx_process.poll() is None:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try: try:
s.connect(("8.8.8.8", 80)) os.killpg(os.getpgid(_sshx_process.pid), 15)
host_ip = s.getsockname()[0]
except Exception: except Exception:
host_ip = "127.0.0.1" try:
finally: _sshx_process.terminate()
s.close() except Exception:
except Exception: pass
host_ip = "127.0.0.1" time.sleep(0.5)
if _sshx_process.poll() is None:
_ttyd_link = f"http://{host_ip}:{_ttyd_port}" try:
logger.info(f"ttyd running at {_ttyd_link}") os.killpg(os.getpgid(_sshx_process.pid), 9)
tool_ctx.yield_result({"status": "running", "link": _ttyd_link, "port": _ttyd_port}) except Exception:
try:
_sshx_process.kill()
except Exception:
pass
_sshx_process = None
_sshx_link = None
if tool_ctx:
tool_ctx.yield_result({"status": "stopped"})
def register(ctx): def register(ctx):
# no background auto-install that blocks - lazy install on demand # warmup download in background — no restart needed
# but we can warm up in thread without requiring restart
def _warmup(): def _warmup():
try: try:
if not _find_ttyd_bin(): if not _find_sshx_bin():
logger.info("Warming up ttyd install (no restart needed)...") logger.info("Warming up sshx install...")
_ensure_ttyd_installed() _ensure_sshx_installed()
except Exception as e: except Exception as e:
logger.debug(f"warmup failed: {e}") logger.debug(f"warmup failed: {e}")
@ -182,20 +298,28 @@ def register(ctx):
name="sshx_start", name="sshx_start",
toolset="sshx-link", toolset="sshx-link",
schema={ schema={
"description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.", "description": "Create a shell at sshx.io and return the shareable link. Auto arch detection, downloads sshx binary if needed.",
"parameters": {"type": "object", "properties": {}, "required": []}, "parameters": {"type": "object", "properties": {}, "required": []},
}, },
handler=_run_ttyd, handler=_run_sshx,
) )
# also register new name for clarity ctx.register_tool(
name="sshx_stop",
toolset="sshx-link",
schema={
"description": "Stop the running sshx session",
"parameters": {"type": "object", "properties": {}, "required": []},
},
handler=_stop_sshx,
)
# alias for compat with old ttyd plugin
ctx.register_tool( ctx.register_tool(
name="ttyd_start", name="ttyd_start",
toolset="sshx-link", toolset="sshx-link",
schema={ schema={
"description": "Start ttyd terminal (alias of sshx_start, auto arch, no restart)", "description": "Alias of sshx_start (kept for compatibility)",
"parameters": {"type": "object", "properties": {}, "required": []}, "parameters": {"type": "object", "properties": {}, "required": []},
}, },
handler=_run_ttyd, handler=_run_sshx,
) )
logger.info("ttyd-link plugin registered (replaces sshx, no restart)") logger.info("sshx-link plugin registered (sshx.io, auto arch, no restart)")

View File

@ -10,13 +10,12 @@
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 + "): " + text.slice(0,120)); throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,300));
} }
} }
function TTYDPage() { 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);
@ -31,21 +30,23 @@
} }
if (res.status === "running" && res.link) { if (res.status === "running" && res.link) {
setLink(res.link); setLink(res.link);
setPort(res.port);
setStatus("running"); setStatus("running");
return; 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 if (res.status === "starting") {
setStatus("installing");
setError("sshx đang khởi động, thử lại sau 2s...");
setTimeout(function(){ safeFetchJSON("/api/plugins/sshx-link/status").then(function(r){ if(r.link){ setLink(r.link); setStatus("running"); setError(null);} }); }, 2000);
} 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 ttyd"); setError(err.message || "Failed to start sshx");
setStatus("error"); setStatus("error");
} }
}, []); }, []);
@ -54,8 +55,8 @@
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");
setError(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@ -66,9 +67,8 @@
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" || res.status === "starting") {
setStatus("installing"); setStatus("installing");
} else if (res.status === "stopped") { } else if (res.status === "stopped") {
if (status === "running") { if (status === "running") {
@ -94,14 +94,15 @@
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, "TTYD Shared Terminal") React.createElement(CardTitle, null, "sshx.io 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 ttyd (auto arch detect)..."), }, "Đang cài / khởi động sshx (auto arch detect)..."),
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Downloading binary for your arch, no restart needed.") React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Đang tải binary từ s3.amazonaws.com/sshx, không cần restart dashboard. Tự động thử lại sau 2s."),
error && React.createElement("p", { className: "text-xs text-yellow-700", style: { marginTop: "0.5rem"}}, error)
) )
); );
} }
@ -109,8 +110,8 @@
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, "TTYD Shared Terminal"), React.createElement(CardTitle, null, "sshx.io Shared Terminal"),
React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running - No dashboard restart needed") React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running — shell đã tạo ở sshx.io")
), ),
React.createElement(CardContent, null, React.createElement(CardContent, null,
React.createElement("a", { React.createElement("a", {
@ -119,19 +120,25 @@
rel: "noopener noreferrer", rel: "noopener noreferrer",
style: { style: {
color: "#60a5fa", color: "#60a5fa",
fontSize: "1rem", fontSize: "1.05rem",
wordBreak: "break-all", wordBreak: "break-all",
textDecoration: "underline", textDecoration: "underline",
display: "block", display: "block",
marginBottom: "0.75rem" marginBottom: "0.75rem",
fontWeight: 600
} }
}, link), }, link),
React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } }, React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" } },
React.createElement(Button, { React.createElement(Button, {
onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); }, onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
variant: "outline", variant: "outline",
size: "sm" size: "sm"
}, "Copy Link"), }, "Copy Link"),
React.createElement(Button, {
onClick: function() { window.open(link, "_blank"); },
variant: "outline",
size: "sm"
}, "Open"),
React.createElement(Button, { React.createElement(Button, {
onClick: stop, onClick: stop,
variant: "destructive", variant: "destructive",
@ -143,18 +150,19 @@
border: "1px solid #333", border: "1px solid #333",
borderRadius: "8px", borderRadius: "8px",
overflow: "hidden", overflow: "hidden",
height: "500px", height: "560px",
background: "#000" background: "#000"
} }
}, },
React.createElement("iframe", { React.createElement("iframe", {
src: link, src: link,
style: { width: "100%", height: "100%", border: "0" }, style: { width: "100%", height: "100%", border: "0" },
title: "TTYD Terminal" title: "sshx Terminal",
allow: "clipboard-read; clipboard-write"
}) })
), ),
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, 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." "Terminal nhúng ở trên (infinite canvas, cộng tác realtime). Nếu iframe bị chặn, bấm Open để mở tab mới. Link có dạng https://sshx.io/s/<id>#<key> — chia sẻ cho user là xong."
) )
) )
); );
@ -162,14 +170,14 @@
return React.createElement(Card, null, return React.createElement(Card, null,
React.createElement(CardHeader, null, React.createElement(CardHeader, null,
React.createElement(CardTitle, null, "TTYD Shared Terminal"), React.createElement(CardTitle, null, "sshx.io Shared Terminal"),
React.createElement("p", { className: "text-xs text-muted-foreground" }, "Auto arch detection, no restart required") React.createElement("p", { className: "text-xs text-muted-foreground" }, "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart")
), ),
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 via ttyd. Works offline, no dependency on sshx.io."), }, "Bấm Start để tạo shell cộng tác trên sshx.io. Plugin sẽ tự tải binary sshx (S3), chạy sshx và parse link https://sshx.io/s/... trả về cho user."),
error && React.createElement("p", { error && React.createElement("p", {
className: "text-sm text-red-500", className: "text-sm text-red-500",
style: { marginBottom: "1rem", whiteSpace: "pre-wrap" } style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
@ -177,10 +185,11 @@
React.createElement(Button, { React.createElement(Button, {
onClick: start, onClick: start,
disabled: status === "loading" disabled: status === "loading"
}, status === "loading" ? "Starting ttyd..." : "Start TTYD") }, status === "loading" ? "Đang tạo shell ở sshx.io..." : "Start sshx → Lấy link"),
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.75rem"}}, "Tương đương lệnh: curl -sSf https://sshx.io/get | sh -s run — nhưng tự động hoàn toàn trong dashboard.")
) )
); );
} }
window.__HERMES_PLUGINS__.register("sshx-link", TTYDPage); window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage);
})(); })();

View File

@ -1,9 +1,9 @@
{ {
"name": "sshx-link", "name": "sshx-link",
"label": "TTYD Terminal", "label": "sshx.io Terminal",
"description": "TTYD shared terminal - auto arch, no restart", "description": "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart",
"icon": "Terminal", "icon": "Terminal",
"version": "1.1.0", "version": "2.0.0",
"tab": { "tab": {
"path": "/sshx", "path": "/sshx",
"position": "end" "position": "end"

View File

@ -6,8 +6,11 @@ import time
import platform import platform
import stat import stat
import urllib.request import urllib.request
import socket import re
import tarfile
import tempfile
import shutil import shutil
import select
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -15,237 +18,289 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_ttyd_link = None _sshx_link = None
_ttyd_pid = None _sshx_process: Optional[subprocess.Popen] = None
_ttyd_port = None _sshx_installing = False
_ttyd_installing = False _sshx_bin = "/tmp/sshx"
_ttyd_bin = "/tmp/ttyd" _sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx")
_ttyd_bin_alt = os.path.expanduser("~/.local/bin/ttyd") _sshx_tar = "/tmp/sshx.tar.gz"
_ttyd_log = "/tmp/ttyd_link.log"
_ARCH_MAP = { LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
"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:
def _detect_sshx_arch():
machine = platform.machine().lower() machine = platform.machine().lower()
arch = _ARCH_MAP.get(machine, "x86_64") system = platform.system().lower()
logger.info(f"Detected arch machine={machine} -> ttyd arch={arch}") if system == "darwin":
return arch suffix = "-apple-darwin"
if machine in ("aarch64", "arm64", "armv8l", "armv8b"):
arch = "aarch64"
elif machine in ("x86_64", "x64", "amd64"):
arch = "x86_64"
else:
arch = "aarch64" if "arm" in machine else "x86_64"
return arch, suffix
suffix = "-unknown-linux-musl"
if machine in ("aarch64", "aarch64_be", "arm64", "armv8b", "armv8l"):
arch = "aarch64"
elif machine in ("x86_64", "x64", "amd64"):
arch = "x86_64"
elif machine == "armv6l":
arch = "arm"
suffix += "eabihf"
elif machine == "armv7l":
arch = "armv7"
suffix += "eabihf"
else:
arch = "x86_64"
return arch, suffix
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]: def _get_sshx_url() -> str:
for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]: arch, suffix = _detect_sshx_arch()
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
logger.info(f"sshx arch={arch} suffix={suffix} url={url}")
return url
def _find_sshx_bin() -> Optional[str]:
for p in [_sshx_bin, _sshx_bin_alt, shutil.which("sshx")]:
if p and os.path.exists(p) and os.access(p, os.X_OK): if p and os.path.exists(p) and os.access(p, os.X_OK):
return p return p
return None return None
def _ensure_ttyd() -> Optional[str]:
"""Ensure ttyd exists, download with arch detection if needed. Returns bin path.""" def _ensure_sshx() -> Optional[str]:
existing = _find_ttyd_bin() existing = _find_sshx_bin()
if existing: if existing:
return existing return existing
global _ttyd_installing global _sshx_installing
_ttyd_installing = True _sshx_installing = True
try: try:
url = _get_ttyd_url() url = _get_sshx_url()
dest = _ttyd_bin dest = _sshx_bin
os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True) os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True)
logger.info(f"Downloading ttyd {url} -> {dest}") tmp_tar = _sshx_tar
# urllib no curl|sh, no dashboard restart needed logger.info(f"Downloading sshx {url} -> {tmp_tar}")
urllib.request.urlretrieve(url, dest) urllib.request.urlretrieve(url, tmp_tar)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) logger.info(f"Extracting {tmp_tar}")
# cache with tarfile.open(tmp_tar, "r:gz") as tf:
member = None
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if base == "sshx" and m.isfile():
member = m
break
if not member:
for m in tf.getmembers():
base = os.path.basename(m.name)
if base.startswith("._"):
continue
if m.isfile():
member = m
break
if not member:
member = tf.getmembers()[0]
tmpdir = tempfile.mkdtemp()
try:
tf.extract(member, path=tmpdir, filter='fully_trusted')
except TypeError:
tf.extract(member, path=tmpdir)
extracted = os.path.join(tmpdir, member.name)
if not os.path.exists(extracted):
for root, _, files in os.walk(tmpdir):
if "sshx" in files:
extracted = os.path.join(root, "sshx")
break
shutil.copy2(extracted, dest)
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
try:
if dest != _sshx_bin_alt:
shutil.copy2(dest, _sshx_bin_alt)
os.chmod(_sshx_bin_alt, os.stat(_sshx_bin_alt).st_mode | stat.S_IEXEC)
except Exception:
pass
shutil.rmtree(tmpdir, ignore_errors=True)
try: try:
if dest != _ttyd_bin_alt: os.remove(tmp_tar)
shutil.copy2(dest, _ttyd_bin_alt)
os.chmod(_ttyd_bin_alt, os.stat(_ttyd_bin_alt).st_mode | stat.S_IEXEC)
except Exception: except Exception:
pass pass
logger.info(f"ttyd installed to {dest}") logger.info(f"sshx installed to {dest}")
return dest return dest
except Exception as e: except Exception as e:
logger.error(f"ttyd install failed: {e}") logger.error(f"sshx install failed: {e}")
return None return None
finally: finally:
_ttyd_installing = False _sshx_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 _ttyd_pid global _sshx_process
if not _ttyd_pid: if not _sshx_process:
return False
try:
os.kill(_ttyd_pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False return False
return _sshx_process.poll() is None
def _kill_ttyd():
global _ttyd_pid, _ttyd_link, _ttyd_port def _kill_sshx():
if _ttyd_pid: global _sshx_process, _sshx_link
if _sshx_process and _sshx_process.poll() is None:
try: try:
os.kill(_ttyd_pid, 15) os.killpg(os.getpgid(_sshx_process.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: except Exception:
pass try:
_sshx_process.terminate()
except Exception:
pass
time.sleep(0.5)
if _sshx_process.poll() is None:
try:
os.killpg(os.getpgid(_sshx_process.pid), 9)
except Exception:
try:
_sshx_process.kill()
except Exception:
pass
_sshx_process = None
_sshx_link = None
def _wait_for_port(port: int, timeout: int = 5) -> bool:
for _ in range(timeout * 10): def _capture_link_sync(proc, timeout=15) -> Optional[str]:
link = None
start = time.time()
output = ""
while time.time() - start < timeout:
if proc.poll() is not None:
try:
rem = proc.stdout.read() or ""
output += rem
except Exception:
pass
break
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=1): r, _, _ = select.select([proc.stdout], [], [], 0.5)
return True if r:
except OSError: line = proc.stdout.readline()
time.sleep(0.1) if not line:
return False time.sleep(0.1)
continue
output += line
logger.info(f"sshx stdout: {line.strip()}")
m = LINK_RE.search(line)
if m:
link = m.group(0)
break
m2 = LINK_RE.search(output)
if m2:
link = m2.group(0)
break
except Exception as e:
logger.debug(f"capture error: {e}")
time.sleep(0.2)
if not link:
m = LINK_RE.search(output)
if m:
link = m.group(0)
if not link:
logger.warning(f"sshx link not found after {timeout}s, output: {output[:600]}")
return link
def _start_ttyd_background(host_header: str = "") -> bool:
global _ttyd_pid, _ttyd_link, _ttyd_port
_kill_ttyd()
bin_path = _ensure_ttyd() def _start_sshx_background() -> bool:
global _sshx_process, _sshx_link
_kill_sshx()
bin_path = _ensure_sshx()
if not bin_path: if not bin_path:
return False return False
logger.info(f"Starting sshx {bin_path}")
_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: try:
proc = subprocess.Popen( proc = subprocess.Popen(
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"], [bin_path, "--quiet"],
stdout=open(_ttyd_log, "w"), stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True,
bufsize=1,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
start_new_session=True, start_new_session=True,
) )
_ttyd_pid = proc.pid _sshx_process = proc
# wait for listening link = _capture_link_sync(proc, timeout=15)
if not _wait_for_port(_ttyd_port, timeout=5): if link:
if proc.poll() is not None: _sshx_link = link
logger.error(f"ttyd exited quickly code={proc.poll()}") logger.info(f"sshx running link={_sshx_link} pid={proc.pid}")
return False return True
logger.warning("ttyd port not ready but proc alive, continuing") # if link not found but process still alive, keep it and return false to let caller handle
if proc.poll() is None:
_ttyd_link = f"http://{host_ip}:{_ttyd_port}" logger.warning("sshx started but link not parsed yet")
logger.info(f"ttyd running link={_ttyd_link} pid={_ttyd_pid}") return False
return True logger.error(f"sshx exited quickly code={proc.poll()}")
_sshx_process = None
return False
except Exception as e: except Exception as e:
logger.error(f"Failed to start ttyd: {e}") logger.error(f"Failed to start sshx: {e}")
return False return False
@router.get("/start") @router.get("/start")
async def start_ttyd(request: Request): async def start_sshx(request: Request):
global _ttyd_link, _ttyd_pid, _ttyd_port global _sshx_link, _sshx_process
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, "pid": _sshx_process.pid if _sshx_process else None}
# install check (no restart needed) bin_path = _find_sshx_bin()
bin_path = _find_ttyd_bin()
if not bin_path: if not bin_path:
# try install synchronously but non-blocking via executor
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
bin_path = await loop.run_in_executor(None, _ensure_ttyd) bin_path = await loop.run_in_executor(None, _ensure_sshx)
if not bin_path: 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"}) return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com"})
host_header = request.headers.get("host", "")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header)) ok = await loop.run_in_executor(None, _start_sshx_background)
if ok and _sshx_link:
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
# if process alive but link not yet, try one more check
if _is_alive():
return JSONResponse(status_code=202, content={"status": "starting", "message": "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None})
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not start in time, check logs"})
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") @router.post("/start")
async def start_ttyd_post(request: Request): async def start_sshx_post(request: Request):
return await start_ttyd(request) return await start_sshx(request)
@router.get("/status") @router.get("/status")
async def ttyd_status(): async def sshx_status():
global _ttyd_link, _ttyd_installing, _ttyd_port global _sshx_link, _sshx_installing, _sshx_process
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, "pid": _sshx_process.pid}
if _ttyd_installing: if _sshx_installing:
return {"status": "installing", "link": None} return {"status": "installing", "link": None}
if _is_alive() and not _sshx_link:
return {"status": "starting", "link": None, "pid": _sshx_process.pid}
return {"status": "stopped", "link": None} return {"status": "stopped", "link": None}
@router.post("/stop") @router.post("/stop")
async def stop_ttyd(): async def stop_sshx():
_kill_ttyd() _kill_sshx()
return {"status": "stopped"} return {"status": "stopped"}
@router.get("/stop") @router.get("/stop")
async def stop_ttyd_get(): async def stop_sshx_get():
_kill_ttyd() _kill_sshx()
return {"status": "stopped"} 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."}
# compat: old ttyd endpoint still works but now maps to sshx
@router.get("/restart-dashboard") @router.get("/restart-dashboard")
async def restart_dashboard_get(): async def restart_dashboard():
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."} return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
@router.post("/restart-dashboard")
async def restart_dashboard_post():
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}

View File

@ -1,9 +1,10 @@
name: sshx-link name: sshx-link
version: 1.1.0 version: 2.0.0
description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard
author: sisyphus author: sisyphus
provides_tools: provides_tools:
- sshx_start - sshx_start
- sshx_stop
- ttyd_start - ttyd_start
provides_hooks: [] provides_hooks: []
requires_env: [] requires_env: []