From 51b27ed10911879bef794c356f42baa5bdbb281c Mon Sep 17 00:00:00 2001 From: 6zev Date: Thu, 3 Sep 2026 04:21:03 +0000 Subject: [PATCH] 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 --- __init__.py | 368 ++++++++++++++++++++++++------------- dashboard/dist/index.js | 59 +++--- dashboard/manifest.json | 6 +- dashboard/plugin_api.py | 395 +++++++++++++++++++++++----------------- plugin.yaml | 5 +- 5 files changed, 511 insertions(+), 322 deletions(-) diff --git a/__init__.py b/__init__.py index 3d4c342..5635145 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,11 @@ """ -ttyd-link plugin (replaces sshx) -================================= -Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart. +sshx-link plugin — creates a shell at sshx.io and returns the shareable link +============================================================================ +- 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 threading import logging @@ -12,167 +14,281 @@ import os import platform import stat import urllib.request -import socket +import re +import tarfile +import tempfile import time logger = logging.getLogger(__name__) -_ttyd_process = None -_ttyd_link = None -_ttyd_port = None +_sshx_process = None +_sshx_link = 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_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 -_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", -} +LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?") -def _detect_arch() -> str: + +def _detect_sshx_arch(): 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 + system = platform.system().lower() + if system == "darwin": + 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 + # 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: - for p in [TTYD_BIN, TTYD_BIN_ALT, shutil.which("ttyd")]: +def _get_sshx_url() -> str: + 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): 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() + +def _ensure_sshx_installed() -> str | None: + """Ensure sshx binary exists, download + extract if needed. Returns path or None.""" + existing = _find_sshx_bin() if existing: - logger.info(f"ttyd already installed at {existing}") + logger.info(f"sshx already installed at {existing}") return existing - url = _get_ttyd_url() - dest = TTYD_BIN - # try ~/.local/bin if /tmp not writable or prefer persistence + url = _get_sshx_url() + dest = SSHX_BIN + os.makedirs(os.path.dirname(SSHX_BIN_ALT), exist_ok=True) + tmp_tar = SSHX_TAR try: - 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) - - # cache copy + logger.info(f"Downloading sshx from {url} -> {tmp_tar}") + urllib.request.urlretrieve(url, tmp_tar) + # extract tar.gz - contains single file 'sshx' + logger.info(f"Extracting {tmp_tar}") + with tarfile.open(tmp_tar, "r:gz") as tf: + # find member named sshx, skip AppleDouble ._ files + 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: - 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) + os.remove(tmp_tar) except Exception: pass - logger.info(f"ttyd installed successfully to {dest}") + logger.info(f"sshx installed successfully to {dest}") return dest except Exception as e: - logger.error(f"ttyd install failed from {url}: {e}") + logger.error(f"sshx 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): +def _is_sshx_alive() -> bool: + 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: - with socket.create_connection(("127.0.0.1", port), timeout=1): - return True - except OSError: - time.sleep(0.1) - return False + # select on stdout fd + r, _, _ = select.select([proc.stdout], [], [], 0.5) + if r: + line = proc.stdout.readline() + 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: - tool_ctx.yield_result({"status": "already_running", "link": _ttyd_link, "port": _ttyd_port}) +def _run_sshx(tool_ctx): + """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 - bin_path = _ensure_ttyd_installed() + bin_path = _ensure_sshx_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"}) + tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx. Check network to s3.amazonaws.com/sshx"}) return - _ttyd_port = _get_free_port() - logger.info(f"Starting ttyd on port {_ttyd_port} using {bin_path}...") - + logger.info(f"Starting sshx via {bin_path} ...") try: - _ttyd_process = subprocess.Popen( - [bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + # --quiet prints only the link (https://sshx.io/s/...), far more reliable to parse + # fallback to no-flag if --quiet not supported (old version) + _sshx_process = subprocess.Popen( + [bin_path, "--quiet"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, 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}"}) + logger.error(f"Failed to start sshx: {e}") + tool_ctx.yield_result({"status": "error", "message": f"Failed to start sshx: {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") + link = _capture_link(_sshx_process, timeout=15) + if link: + _sshx_link = link + logger.info(f"sshx running at {_sshx_link} pid={_sshx_process.pid}") + tool_ctx.yield_result({"status": "running", "link": _sshx_link, "pid": _sshx_process.pid}) + 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: - hostname = socket.gethostname() - # try to get outward IP, fallback to localhost - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + +def _stop_sshx(tool_ctx=None): + global _sshx_process, _sshx_link + if _sshx_process and _sshx_process.poll() is None: try: - s.connect(("8.8.8.8", 80)) - host_ip = s.getsockname()[0] + os.killpg(os.getpgid(_sshx_process.pid), 15) 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}) + 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 + if tool_ctx: + tool_ctx.yield_result({"status": "stopped"}) def register(ctx): - # no background auto-install that blocks - lazy install on demand - # but we can warm up in thread without requiring restart + # warmup download in background — no restart needed def _warmup(): try: - if not _find_ttyd_bin(): - logger.info("Warming up ttyd install (no restart needed)...") - _ensure_ttyd_installed() + if not _find_sshx_bin(): + logger.info("Warming up sshx install...") + _ensure_sshx_installed() except Exception as e: logger.debug(f"warmup failed: {e}") @@ -182,20 +298,28 @@ def register(ctx): name="sshx_start", toolset="sshx-link", 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": []}, }, - 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( name="ttyd_start", toolset="sshx-link", 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": []}, }, - 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)") diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 11bb9cf..b0606da 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -10,13 +10,12 @@ 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 + "): " + 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 [port, setPort] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); @@ -31,21 +30,23 @@ } 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 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 { setError(res.message || "No link returned"); setStatus("error"); } } catch (err) { - setError(err.message || "Failed to start ttyd"); + setError(err.message || "Failed to start sshx"); setStatus("error"); } }, []); @@ -54,8 +55,8 @@ try { await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" }); setLink(null); - setPort(null); setStatus("idle"); + setError(null); } catch (err) { setError(err.message); } @@ -66,9 +67,8 @@ 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") { + } else if (res.status === "installing" || res.status === "starting") { setStatus("installing"); } else if (res.status === "stopped") { if (status === "running") { @@ -94,14 +94,15 @@ if (status === "installing") { return React.createElement(Card, 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("p", { className: "text-sm text-yellow-500", style: { fontWeight: 600 } - }, "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.") + }, "Đang cài / khởi động sshx (auto arch detect)..."), + 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) { return React.createElement(Card, null, React.createElement(CardHeader, null, - 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(CardTitle, null, "sshx.io Shared Terminal"), + React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running — shell đã tạo ở sshx.io") ), React.createElement(CardContent, null, React.createElement("a", { @@ -119,19 +120,25 @@ rel: "noopener noreferrer", style: { color: "#60a5fa", - fontSize: "1rem", + fontSize: "1.05rem", wordBreak: "break-all", textDecoration: "underline", display: "block", - marginBottom: "0.75rem" + marginBottom: "0.75rem", + fontWeight: 600 } }, 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, { onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); }, variant: "outline", size: "sm" }, "Copy Link"), + React.createElement(Button, { + onClick: function() { window.open(link, "_blank"); }, + variant: "outline", + size: "sm" + }, "Open"), React.createElement(Button, { onClick: stop, variant: "destructive", @@ -143,18 +150,19 @@ border: "1px solid #333", borderRadius: "8px", overflow: "hidden", - height: "500px", + height: "560px", background: "#000" } }, React.createElement("iframe", { src: link, 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" } }, - "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/# — chia sẻ cho user là xong." ) ) ); @@ -162,14 +170,14 @@ return React.createElement(Card, null, React.createElement(CardHeader, null, - React.createElement(CardTitle, null, "TTYD Shared Terminal"), - React.createElement("p", { className: "text-xs text-muted-foreground" }, "Auto arch detection, no restart required") + React.createElement(CardTitle, null, "sshx.io Shared Terminal"), + 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("p", { className: "text-sm text-muted-foreground", 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", { className: "text-sm text-red-500", style: { marginBottom: "1rem", whiteSpace: "pre-wrap" } @@ -177,10 +185,11 @@ React.createElement(Button, { onClick: start, 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); })(); diff --git a/dashboard/manifest.json b/dashboard/manifest.json index faf2750..bbac14d 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -1,9 +1,9 @@ { "name": "sshx-link", - "label": "TTYD Terminal", - "description": "TTYD shared terminal - auto arch, no restart", + "label": "sshx.io Terminal", + "description": "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart", "icon": "Terminal", - "version": "1.1.0", + "version": "2.0.0", "tab": { "path": "/sshx", "position": "end" diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 50ae557..a08452d 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -6,8 +6,11 @@ import time import platform import stat import urllib.request -import socket +import re +import tarfile +import tempfile import shutil +import select from typing import Optional from fastapi import APIRouter, Request from fastapi.responses import JSONResponse @@ -15,237 +18,289 @@ from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) router = APIRouter() -_ttyd_link = None -_ttyd_pid = None -_ttyd_port = None -_ttyd_installing = False -_ttyd_bin = "/tmp/ttyd" -_ttyd_bin_alt = os.path.expanduser("~/.local/bin/ttyd") -_ttyd_log = "/tmp/ttyd_link.log" +_sshx_link = None +_sshx_process: Optional[subprocess.Popen] = None +_sshx_installing = False +_sshx_bin = "/tmp/sshx" +_sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx") +_sshx_tar = "/tmp/sshx.tar.gz" -_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", -} +LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?") -def _detect_arch() -> str: + +def _detect_sshx_arch(): machine = platform.machine().lower() - arch = _ARCH_MAP.get(machine, "x86_64") - logger.info(f"Detected arch machine={machine} -> ttyd arch={arch}") - return arch + system = platform.system().lower() + if system == "darwin": + 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]: - for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]: +def _get_sshx_url() -> str: + 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): 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() + +def _ensure_sshx() -> Optional[str]: + existing = _find_sshx_bin() if existing: return existing - global _ttyd_installing - _ttyd_installing = True + global _sshx_installing + _sshx_installing = True try: - url = _get_ttyd_url() - dest = _ttyd_bin - os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True) - logger.info(f"Downloading ttyd {url} -> {dest}") - # urllib no curl|sh, no dashboard restart needed - urllib.request.urlretrieve(url, dest) - os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC) - # cache + url = _get_sshx_url() + dest = _sshx_bin + os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True) + tmp_tar = _sshx_tar + logger.info(f"Downloading sshx {url} -> {tmp_tar}") + urllib.request.urlretrieve(url, tmp_tar) + logger.info(f"Extracting {tmp_tar}") + 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: - 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) + os.remove(tmp_tar) except Exception: pass - logger.info(f"ttyd installed to {dest}") + logger.info(f"sshx installed to {dest}") return dest except Exception as e: - logger.error(f"ttyd install failed: {e}") + logger.error(f"sshx install failed: {e}") return None 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: - global _ttyd_pid - if not _ttyd_pid: - return False - try: - os.kill(_ttyd_pid, 0) - return True - except (ProcessLookupError, PermissionError): + global _sshx_process + if not _sshx_process: return False + return _sshx_process.poll() is None -def _kill_ttyd(): - global _ttyd_pid, _ttyd_link, _ttyd_port - if _ttyd_pid: + +def _kill_sshx(): + global _sshx_process, _sshx_link + if _sshx_process and _sshx_process.poll() is None: 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) + os.killpg(os.getpgid(_sshx_process.pid), 15) 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: - with socket.create_connection(("127.0.0.1", port), timeout=1): - return True - except OSError: - time.sleep(0.1) - return False + r, _, _ = select.select([proc.stdout], [], [], 0.5) + if r: + line = proc.stdout.readline() + 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 + 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: 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})") + logger.info(f"Starting sshx {bin_path}") try: proc = subprocess.Popen( - [bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"], - stdout=open(_ttyd_log, "w"), + [bin_path, "--quiet"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, + bufsize=1, 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 + _sshx_process = proc + link = _capture_link_sync(proc, timeout=15) + if link: + _sshx_link = link + logger.info(f"sshx running link={_sshx_link} pid={proc.pid}") + return True + # if link not found but process still alive, keep it and return false to let caller handle + if proc.poll() is None: + logger.warning("sshx started but link not parsed yet") + return False + logger.error(f"sshx exited quickly code={proc.poll()}") + _sshx_process = None + return False except Exception as e: - logger.error(f"Failed to start ttyd: {e}") + logger.error(f"Failed to start sshx: {e}") return False + @router.get("/start") -async def start_ttyd(request: Request): - global _ttyd_link, _ttyd_pid, _ttyd_port - if _is_alive() and _ttyd_link: - return {"status": "running", "link": _ttyd_link, "port": _ttyd_port} +async def start_sshx(request: Request): + global _sshx_link, _sshx_process + if _is_alive() and _sshx_link: + return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None} - # install check (no restart needed) - bin_path = _find_ttyd_bin() + bin_path = _find_sshx_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) + bin_path = await loop.run_in_executor(None, _ensure_sshx) 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() - 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") -async def start_ttyd_post(request: Request): - return await start_ttyd(request) +async def start_sshx_post(request: Request): + return await start_sshx(request) + @router.get("/status") -async def ttyd_status(): - global _ttyd_link, _ttyd_installing, _ttyd_port - if _is_alive() and _ttyd_link: - return {"status": "running", "link": _ttyd_link, "port": _ttyd_port} - if _ttyd_installing: +async def sshx_status(): + global _sshx_link, _sshx_installing, _sshx_process + if _is_alive() and _sshx_link: + return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid} + if _sshx_installing: 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} + @router.post("/stop") -async def stop_ttyd(): - _kill_ttyd() +async def stop_sshx(): + _kill_sshx() return {"status": "stopped"} + @router.get("/stop") -async def stop_ttyd_get(): - _kill_ttyd() +async def stop_sshx_get(): + _kill_sshx() 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") -async def restart_dashboard_get(): - return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."} +async def restart_dashboard(): + 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."} diff --git a/plugin.yaml b/plugin.yaml index ffe0622..64ac5c5 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,9 +1,10 @@ name: sshx-link -version: 1.1.0 -description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart +version: 2.0.0 +description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard author: sisyphus provides_tools: - sshx_start + - sshx_stop - ttyd_start provides_hooks: [] requires_env: []