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

View File

@ -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/<id>#<key> — 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);
})();

View File

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

View File

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

View File

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