feat: replace sshx with ttyd - auto arch detection, no curl|sh, no dashboard restart
- __init__.py: replace curl|sh sshx install with urllib ttyd download, arch map x86_64/aarch64/arm/armhf/i686/mips*, cache to ~/.local/bin, no systemd restart, lazy warmup - dashboard/plugin_api.py: full ttyd backend, auto-detect arch via platform.machine(), download from github releases, free port alloc, host header aware link, no _trigger_dashboard_restart, deprecated restart endpoint kept no-op - dashboard/dist/index.js: ttyd UI with iframe embed, copy link, installing state, error handling, no restart notice - plugin.yaml/manifest.json: bump 1.1.0, description ttyd, provides_tools ttyd_start
This commit is contained in:
parent
ffece76498
commit
81293d2b05
|
|
@ -0,0 +1 @@
|
||||||
|
__pycache__/
|
||||||
267
__init__.py
267
__init__.py
|
|
@ -1,124 +1,201 @@
|
||||||
"""
|
"""
|
||||||
sshx-link plugin
|
ttyd-link plugin (replaces sshx)
|
||||||
================
|
=================================
|
||||||
Downloads sshx, starts a shared terminal session,
|
Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart.
|
||||||
and returns the shareable link.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
|
import stat
|
||||||
|
import urllib.request
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_sshx_process = None
|
_ttyd_process = None
|
||||||
_sshx_link = None
|
_ttyd_link = None
|
||||||
|
_ttyd_port = None
|
||||||
|
|
||||||
|
TTYD_BIN = "/tmp/ttyd"
|
||||||
|
TTYD_DIR = os.path.expanduser("~/.local/bin")
|
||||||
|
TTYD_BIN_ALT = os.path.join(TTYD_DIR, "ttyd")
|
||||||
|
|
||||||
def _find_link_from_output(line: str) -> str | None:
|
# arch mapping for ttyd releases: https://github.com/tsl0922/ttyd/releases
|
||||||
"""Extract sshx link from process output."""
|
_ARCH_MAP = {
|
||||||
# sshx outputs links like: https://sshx.io/s/xxxxx
|
"x86_64": "x86_64",
|
||||||
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line)
|
"amd64": "x86_64",
|
||||||
return match.group(0) if match else None
|
"aarch64": "aarch64",
|
||||||
|
"arm64": "aarch64",
|
||||||
|
"armv8l": "aarch64",
|
||||||
|
"armv7l": "arm",
|
||||||
|
"armv6l": "arm",
|
||||||
|
"arm": "arm",
|
||||||
|
"armhf": "armhf",
|
||||||
|
"i386": "i686",
|
||||||
|
"i686": "i686",
|
||||||
|
"mips": "mips",
|
||||||
|
"mips64": "mips64",
|
||||||
|
"mips64el": "mips64el",
|
||||||
|
"mipsel": "mipsel",
|
||||||
|
"s390x": "s390x",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _detect_arch() -> str:
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
# handle uname -m variations
|
||||||
|
arch = _ARCH_MAP.get(machine, "x86_64")
|
||||||
|
logger.info(f"Detected machine={machine} -> ttyd arch={arch}")
|
||||||
|
return arch
|
||||||
|
|
||||||
def _ensure_sshx_installed() -> bool:
|
def _get_ttyd_url() -> str:
|
||||||
"""Download and install sshx binary if not present."""
|
arch = _detect_arch()
|
||||||
if shutil.which("sshx"):
|
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
|
||||||
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Downloading sshx...")
|
def _find_ttyd_bin() -> str | None:
|
||||||
|
for p in [TTYD_BIN, TTYD_BIN_ALT, shutil.which("ttyd")]:
|
||||||
|
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_ttyd_installed() -> str | None:
|
||||||
|
"""Ensure ttyd binary exists, auto-download with arch detection. Returns bin path or None."""
|
||||||
|
existing = _find_ttyd_bin()
|
||||||
|
if existing:
|
||||||
|
logger.info(f"ttyd already installed at {existing}")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
url = _get_ttyd_url()
|
||||||
|
dest = TTYD_BIN
|
||||||
|
# try ~/.local/bin if /tmp not writable or prefer persistence
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
os.makedirs(TTYD_DIR, exist_ok=True)
|
||||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
# we download to /tmp first then also copy to ~/.local/bin for caching
|
||||||
capture_output=True,
|
logger.info(f"Downloading ttyd from {url} -> {dest}")
|
||||||
text=True,
|
urllib.request.urlretrieve(url, dest)
|
||||||
timeout=60,
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("sshx install failed: %s", result.stderr)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("sshx install timed out")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
# cache copy
|
||||||
def _run_sshx(tool_ctx):
|
|
||||||
"""Run sshx serve in background, capture the link."""
|
|
||||||
global _sshx_process, _sshx_link
|
|
||||||
|
|
||||||
if _sshx_process and _sshx_process.poll() is None:
|
|
||||||
# Already running
|
|
||||||
tool_ctx.yield_result({"status": "already_running", "link": _sshx_link})
|
|
||||||
return
|
|
||||||
|
|
||||||
if not _ensure_sshx_installed():
|
|
||||||
tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx"})
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting sshx...")
|
|
||||||
_sshx_process = subprocess.Popen(
|
|
||||||
["sshx", "--quiet"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
_sshx_link = None
|
|
||||||
|
|
||||||
def _reader():
|
|
||||||
global _sshx_link
|
|
||||||
for line in _sshx_process.stdout:
|
|
||||||
line = line.strip()
|
|
||||||
logger.debug("sshx: %s", line)
|
|
||||||
link = _find_link_from_output(line)
|
|
||||||
if link:
|
|
||||||
_sshx_link = link
|
|
||||||
tool_ctx.yield_result({"status": "running", "link": link})
|
|
||||||
return
|
|
||||||
# Process ended without outputting a link
|
|
||||||
tool_ctx.yield_result({"status": "error", "message": "sshx exited without producing a link"})
|
|
||||||
|
|
||||||
threading.Thread(target=_reader, daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_install_sshx():
|
|
||||||
if shutil.which("sshx"):
|
|
||||||
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
|
||||||
return
|
|
||||||
logger.info("Auto-installing sshx...")
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
if dest != TTYD_BIN_ALT:
|
||||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
import shutil as _sh
|
||||||
capture_output=True, text=True, timeout=60,
|
_sh.copy2(dest, TTYD_BIN_ALT)
|
||||||
)
|
os.chmod(TTYD_BIN_ALT, os.stat(TTYD_BIN_ALT).st_mode | stat.S_IEXEC)
|
||||||
if result.returncode == 0 and shutil.which("sshx"):
|
except Exception:
|
||||||
logger.info("sshx installed successfully")
|
pass
|
||||||
else:
|
logger.info(f"ttyd installed successfully to {dest}")
|
||||||
logger.error("sshx install failed: %s", result.stderr)
|
return dest
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("sshx auto-install error: %s", e)
|
logger.error(f"ttyd install failed from {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_free_port() -> int:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
def _wait_for_port(port: int, timeout: int = 5) -> bool:
|
||||||
|
for _ in range(timeout * 10):
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _run_ttyd(tool_ctx):
|
||||||
|
"""Run ttyd in background, return link."""
|
||||||
|
global _ttyd_process, _ttyd_link, _ttyd_port
|
||||||
|
|
||||||
|
if _ttyd_process and _ttyd_process.poll() is None and _ttyd_link:
|
||||||
|
tool_ctx.yield_result({"status": "already_running", "link": _ttyd_link, "port": _ttyd_port})
|
||||||
|
return
|
||||||
|
|
||||||
|
bin_path = _ensure_ttyd_installed()
|
||||||
|
if not bin_path:
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": "Failed to install ttyd (auto arch detect failed). Check network to github.com"})
|
||||||
|
return
|
||||||
|
|
||||||
|
_ttyd_port = _get_free_port()
|
||||||
|
logger.info(f"Starting ttyd on port {_ttyd_port} using {bin_path}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_ttyd_process = subprocess.Popen(
|
||||||
|
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start ttyd: {e}")
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": f"Failed to start ttyd: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _wait_for_port(_ttyd_port, timeout=5):
|
||||||
|
# process might have died
|
||||||
|
if _ttyd_process.poll() is not None:
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": "ttyd exited immediately"})
|
||||||
|
return
|
||||||
|
logger.warning("ttyd port not ready but continuing")
|
||||||
|
|
||||||
|
# construct link - use host detection via socket
|
||||||
|
try:
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
# try to get outward IP, fallback to localhost
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
host_ip = s.getsockname()[0]
|
||||||
|
except Exception:
|
||||||
|
host_ip = "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
host_ip = "127.0.0.1"
|
||||||
|
|
||||||
|
_ttyd_link = f"http://{host_ip}:{_ttyd_port}"
|
||||||
|
logger.info(f"ttyd running at {_ttyd_link}")
|
||||||
|
tool_ctx.yield_result({"status": "running", "link": _ttyd_link, "port": _ttyd_port})
|
||||||
|
|
||||||
|
|
||||||
def register(ctx):
|
def register(ctx):
|
||||||
threading.Thread(target=_auto_install_sshx, daemon=True).start()
|
# no background auto-install that blocks - lazy install on demand
|
||||||
|
# but we can warm up in thread without requiring restart
|
||||||
|
def _warmup():
|
||||||
|
try:
|
||||||
|
if not _find_ttyd_bin():
|
||||||
|
logger.info("Warming up ttyd install (no restart needed)...")
|
||||||
|
_ensure_ttyd_installed()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"warmup failed: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=_warmup, daemon=True).start()
|
||||||
|
|
||||||
ctx.register_tool(
|
ctx.register_tool(
|
||||||
name="sshx_start",
|
name="sshx_start",
|
||||||
toolset="sshx-link",
|
toolset="sshx-link",
|
||||||
schema={
|
schema={
|
||||||
"description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.",
|
"description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.",
|
||||||
"parameters": {
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": [],
|
|
||||||
},
|
},
|
||||||
},
|
handler=_run_ttyd,
|
||||||
handler=_run_sshx,
|
|
||||||
)
|
)
|
||||||
logger.info("sshx-link plugin registered")
|
# also register new name for clarity
|
||||||
|
ctx.register_tool(
|
||||||
|
name="ttyd_start",
|
||||||
|
toolset="sshx-link",
|
||||||
|
schema={
|
||||||
|
"description": "Start ttyd terminal (alias of sshx_start, auto arch, no restart)",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
|
},
|
||||||
|
handler=_run_ttyd,
|
||||||
|
)
|
||||||
|
logger.info("ttyd-link plugin registered (replaces sshx, no restart)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,21 @@
|
||||||
const { useState, useEffect, useCallback } = SDK.hooks;
|
const { useState, useEffect, useCallback } = SDK.hooks;
|
||||||
const { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components;
|
const { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components;
|
||||||
|
|
||||||
const fetchJSON = SDK.fetchJSON;
|
|
||||||
|
|
||||||
async function safeFetchJSON(path, opts) {
|
async function safeFetchJSON(path, opts) {
|
||||||
const res = await fetch(path, opts || {});
|
const res = await fetch(path, opts || {});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
try { return JSON.parse(text); } catch (_) {
|
try { return JSON.parse(text); } catch (_) {
|
||||||
throw new Error("Server returned non-JSON (status " + res.status + ")");
|
throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,120));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPost(path) {
|
function TTYDPage() {
|
||||||
const res = await safeFetchJSON(path, { method: "POST" });
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SSHXPage() {
|
|
||||||
const [link, setLink] = useState(null);
|
const [link, setLink] = useState(null);
|
||||||
|
const [port, setPort] = useState(null);
|
||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const startSSHX = useCallback(async function () {
|
const start = useCallback(async function () {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -35,24 +29,32 @@
|
||||||
setStatus("installing");
|
setStatus("installing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.status === "running" && res.link) {
|
||||||
|
setLink(res.link);
|
||||||
|
setPort(res.port);
|
||||||
|
setStatus("running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
res = await safeFetchJSON("/api/plugins/sshx-link/start");
|
res = await safeFetchJSON("/api/plugins/sshx-link/start");
|
||||||
if (res.link) {
|
if (res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
|
setPort(res.port || null);
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
} else {
|
} else {
|
||||||
setError(res.message || "No link returned");
|
setError(res.message || "No link returned");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to start sshx");
|
setError(err.message || "Failed to start ttyd");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopSSHX = useCallback(async function () {
|
const stop = useCallback(async function () {
|
||||||
try {
|
try {
|
||||||
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
||||||
setLink(null);
|
setLink(null);
|
||||||
|
setPort(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -64,12 +66,18 @@
|
||||||
const res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
const res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
||||||
if (res.status === "running" && res.link) {
|
if (res.status === "running" && res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
|
setPort(res.port || null);
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
} else if (res.status === "installing") {
|
} else if (res.status === "installing") {
|
||||||
setStatus("installing");
|
setStatus("installing");
|
||||||
|
} else if (res.status === "stopped") {
|
||||||
|
if (status === "running") {
|
||||||
|
setLink(null);
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, []);
|
}, [status]);
|
||||||
|
|
||||||
useEffect(function () {
|
useEffect(function () {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
|
|
@ -86,13 +94,14 @@
|
||||||
if (status === "installing") {
|
if (status === "installing") {
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "SSHX Shared Terminal")
|
React.createElement(CardTitle, null, "TTYD Shared Terminal")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("p", {
|
React.createElement("p", {
|
||||||
className: "text-sm text-yellow-500",
|
className: "text-sm text-yellow-500",
|
||||||
style: { fontWeight: 600 }
|
style: { fontWeight: 600 }
|
||||||
}, "Installing sshx...")
|
}, "Installing ttyd (auto arch detect)..."),
|
||||||
|
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Downloading binary for your arch, no restart needed.")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -100,29 +109,52 @@
|
||||||
if (status === "running" && link) {
|
if (status === "running" && link) {
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "SSHX Shared Terminal")
|
React.createElement(CardTitle, null, "TTYD Shared Terminal"),
|
||||||
|
React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running - No dashboard restart needed")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("p", {
|
|
||||||
className: "text-sm text-green-500",
|
|
||||||
style: { fontWeight: 600, marginBottom: "0.5rem" }
|
|
||||||
}, "Running"),
|
|
||||||
React.createElement("a", {
|
React.createElement("a", {
|
||||||
href: link,
|
href: link,
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
rel: "noopener noreferrer",
|
rel: "noopener noreferrer",
|
||||||
style: {
|
style: {
|
||||||
color: "#60a5fa",
|
color: "#60a5fa",
|
||||||
fontSize: "1.1rem",
|
fontSize: "1rem",
|
||||||
wordBreak: "break-all",
|
wordBreak: "break-all",
|
||||||
textDecoration: "underline"
|
textDecoration: "underline",
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.75rem"
|
||||||
}
|
}
|
||||||
}, link),
|
}, link),
|
||||||
React.createElement("div", { style: { marginTop: "1rem" } },
|
React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } },
|
||||||
React.createElement(Button, {
|
React.createElement(Button, {
|
||||||
onClick: stopSSHX,
|
onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
|
||||||
variant: "destructive"
|
variant: "outline",
|
||||||
|
size: "sm"
|
||||||
|
}, "Copy Link"),
|
||||||
|
React.createElement(Button, {
|
||||||
|
onClick: stop,
|
||||||
|
variant: "destructive",
|
||||||
|
size: "sm"
|
||||||
}, "Stop")
|
}, "Stop")
|
||||||
|
),
|
||||||
|
React.createElement("div", {
|
||||||
|
style: {
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "8px",
|
||||||
|
overflow: "hidden",
|
||||||
|
height: "500px",
|
||||||
|
background: "#000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
React.createElement("iframe", {
|
||||||
|
src: link,
|
||||||
|
style: { width: "100%", height: "100%", border: "0" },
|
||||||
|
title: "TTYD Terminal"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } },
|
||||||
|
"Terminal embedded above. Open in new tab if iframe blocked by CSP."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -130,24 +162,25 @@
|
||||||
|
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "SSHX Shared Terminal")
|
React.createElement(CardTitle, null, "TTYD Shared Terminal"),
|
||||||
|
React.createElement("p", { className: "text-xs text-muted-foreground" }, "Auto arch detection, no restart required")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("p", {
|
React.createElement("p", {
|
||||||
className: "text-sm text-muted-foreground",
|
className: "text-sm text-muted-foreground",
|
||||||
style: { marginBottom: "1rem" }
|
style: { marginBottom: "1rem" }
|
||||||
}, "Start a shared terminal session. Others can join via the link."),
|
}, "Start a shared terminal via ttyd. Works offline, no dependency on sshx.io."),
|
||||||
error && React.createElement("p", {
|
error && React.createElement("p", {
|
||||||
className: "text-sm text-red-500",
|
className: "text-sm text-red-500",
|
||||||
style: { marginBottom: "1rem" }
|
style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
|
||||||
}, "Error: " + error),
|
}, "Error: " + error),
|
||||||
React.createElement(Button, {
|
React.createElement(Button, {
|
||||||
onClick: startSSHX,
|
onClick: start,
|
||||||
disabled: status === "loading"
|
disabled: status === "loading"
|
||||||
}, status === "loading" ? "Starting..." : "Start SSHX")
|
}, status === "loading" ? "Starting ttyd..." : "Start TTYD")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage);
|
window.__HERMES_PLUGINS__.register("sshx-link", TTYDPage);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "sshx-link",
|
"name": "sshx-link",
|
||||||
"label": "SSHX",
|
"label": "TTYD Terminal",
|
||||||
"description": "Start a shared terminal session and get the link",
|
"description": "TTYD shared terminal - auto arch, no restart",
|
||||||
"icon": "Terminal",
|
"icon": "Terminal",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"tab": {
|
"tab": {
|
||||||
"path": "/sshx",
|
"path": "/sshx",
|
||||||
"position": "end"
|
"position": "end"
|
||||||
|
|
|
||||||
|
|
@ -1,199 +1,251 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import platform
|
||||||
|
import stat
|
||||||
|
import urllib.request
|
||||||
|
import socket
|
||||||
|
import shutil
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, BackgroundTasks
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_sshx_link = None
|
_ttyd_link = None
|
||||||
_sshx_pid = None
|
_ttyd_pid = None
|
||||||
_sshx_installing = False
|
_ttyd_port = None
|
||||||
_sshx_log = "/tmp/sshx_link.log"
|
_ttyd_installing = False
|
||||||
_sshx_pid_file = "/tmp/sshx_link.pid"
|
_ttyd_bin = "/tmp/ttyd"
|
||||||
|
_ttyd_bin_alt = os.path.expanduser("~/.local/bin/ttyd")
|
||||||
|
_ttyd_log = "/tmp/ttyd_link.log"
|
||||||
|
|
||||||
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?")
|
_ARCH_MAP = {
|
||||||
|
"x86_64": "x86_64",
|
||||||
|
"amd64": "x86_64",
|
||||||
|
"aarch64": "aarch64",
|
||||||
|
"arm64": "aarch64",
|
||||||
|
"armv8l": "aarch64",
|
||||||
|
"armv7l": "arm",
|
||||||
|
"armv6l": "arm",
|
||||||
|
"arm": "arm",
|
||||||
|
"armhf": "armhf",
|
||||||
|
"i386": "i686",
|
||||||
|
"i686": "i686",
|
||||||
|
"mips": "mips",
|
||||||
|
"mips64": "mips64",
|
||||||
|
"mips64el": "mips64el",
|
||||||
|
"mipsel": "mipsel",
|
||||||
|
"s390x": "s390x",
|
||||||
|
}
|
||||||
|
|
||||||
import threading as _threading
|
def _detect_arch() -> str:
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
arch = _ARCH_MAP.get(machine, "x86_64")
|
||||||
|
logger.info(f"Detected arch machine={machine} -> ttyd arch={arch}")
|
||||||
|
return arch
|
||||||
|
|
||||||
|
def _get_ttyd_url() -> str:
|
||||||
|
arch = _detect_arch()
|
||||||
|
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
|
||||||
|
|
||||||
def _auto_install():
|
def _find_ttyd_bin() -> Optional[str]:
|
||||||
global _sshx_installing
|
for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]:
|
||||||
if shutil.which("sshx"):
|
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
||||||
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
return p
|
||||||
return
|
return None
|
||||||
_sshx_installing = True
|
|
||||||
logger.info("Auto-installing sshx...")
|
def _ensure_ttyd() -> Optional[str]:
|
||||||
|
"""Ensure ttyd exists, download with arch detection if needed. Returns bin path."""
|
||||||
|
existing = _find_ttyd_bin()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
global _ttyd_installing
|
||||||
|
_ttyd_installing = True
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
url = _get_ttyd_url()
|
||||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
dest = _ttyd_bin
|
||||||
capture_output=True, timeout=60,
|
os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True)
|
||||||
)
|
logger.info(f"Downloading ttyd {url} -> {dest}")
|
||||||
if shutil.which("sshx"):
|
# urllib no curl|sh, no dashboard restart needed
|
||||||
logger.info("sshx installed successfully")
|
urllib.request.urlretrieve(url, dest)
|
||||||
_trigger_dashboard_restart()
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
||||||
else:
|
# cache
|
||||||
logger.error("sshx install failed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("sshx auto-install error: %s", e)
|
|
||||||
finally:
|
|
||||||
_sshx_installing = False
|
|
||||||
|
|
||||||
|
|
||||||
@router.on_event("startup")
|
|
||||||
def _startup_auto_install():
|
|
||||||
_threading.Thread(target=_auto_install, daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sshx() -> tuple[bool, bool]:
|
|
||||||
"""Ensure sshx is installed. Returns (success, needs_restart)."""
|
|
||||||
if shutil.which("sshx"):
|
|
||||||
return True, False
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
if dest != _ttyd_bin_alt:
|
||||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
shutil.copy2(dest, _ttyd_bin_alt)
|
||||||
capture_output=True, timeout=60,
|
os.chmod(_ttyd_bin_alt, os.stat(_ttyd_bin_alt).st_mode | stat.S_IEXEC)
|
||||||
)
|
except Exception:
|
||||||
if shutil.which("sshx"):
|
|
||||||
return True, True # newly installed, needs restart
|
|
||||||
return False, False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("sshx install failed: %s", e)
|
|
||||||
return False, False
|
|
||||||
|
|
||||||
|
|
||||||
def _trigger_dashboard_restart():
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
["nohup", "bash", "-c",
|
|
||||||
"sleep 3 && sudo systemctl restart hermes-dashboard"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to trigger dashboard restart: %s", e)
|
|
||||||
|
|
||||||
|
|
||||||
def _kill_sshx():
|
|
||||||
global _sshx_pid, _sshx_link
|
|
||||||
if _sshx_pid:
|
|
||||||
try:
|
|
||||||
os.kill(_sshx_pid, 15)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
pass
|
pass
|
||||||
_sshx_pid = None
|
logger.info(f"ttyd installed to {dest}")
|
||||||
_sshx_link = None
|
return dest
|
||||||
for f in [_sshx_log, _sshx_pid_file]:
|
except Exception as e:
|
||||||
if os.path.exists(f):
|
logger.error(f"ttyd install failed: {e}")
|
||||||
os.remove(f)
|
return None
|
||||||
|
finally:
|
||||||
|
_ttyd_installing = False
|
||||||
|
|
||||||
|
def _get_free_port() -> int:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
def _is_alive() -> bool:
|
def _is_alive() -> bool:
|
||||||
global _sshx_pid
|
global _ttyd_pid
|
||||||
if not _sshx_pid:
|
if not _ttyd_pid:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
os.kill(_sshx_pid, 0)
|
os.kill(_ttyd_pid, 0)
|
||||||
return True
|
return True
|
||||||
except (ProcessLookupError, PermissionError):
|
except (ProcessLookupError, PermissionError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _kill_ttyd():
|
||||||
def _read_link_from_log() -> Optional[str]:
|
global _ttyd_pid, _ttyd_link, _ttyd_port
|
||||||
if not os.path.exists(_sshx_log):
|
if _ttyd_pid:
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
with open(_sshx_log) as f:
|
os.kill(_ttyd_pid, 15)
|
||||||
content = f.read()
|
time.sleep(0.3)
|
||||||
m = LINK_RE.search(content)
|
# force kill if still alive
|
||||||
if m:
|
try:
|
||||||
return m.group(0)
|
os.kill(_ttyd_pid, 0)
|
||||||
|
os.kill(_ttyd_pid, 9)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
_ttyd_pid = None
|
||||||
|
_ttyd_link = None
|
||||||
|
_ttyd_port = None
|
||||||
|
if os.path.exists(_ttyd_log):
|
||||||
|
try:
|
||||||
|
os.remove(_ttyd_log)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
|
||||||
|
|
||||||
|
def _wait_for_port(port: int, timeout: int = 5) -> bool:
|
||||||
|
for _ in range(timeout * 10):
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
def _start_sshx_background():
|
def _start_ttyd_background(host_header: str = "") -> bool:
|
||||||
global _sshx_pid, _sshx_link
|
global _ttyd_pid, _ttyd_link, _ttyd_port
|
||||||
_kill_sshx()
|
_kill_ttyd()
|
||||||
_sshx_link = None
|
|
||||||
|
|
||||||
log_fh = open(_sshx_log, "w")
|
bin_path = _ensure_ttyd()
|
||||||
|
if not bin_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_ttyd_port = _get_free_port()
|
||||||
|
# derive host for link: use Host header if available, else local IP
|
||||||
|
host_ip = "127.0.0.1"
|
||||||
|
if host_header:
|
||||||
|
# Host header may be "dashboard.example.com:8000" -> take hostname
|
||||||
|
host_ip = host_header.split(":")[0]
|
||||||
|
# if host is localhost, try to get outward IP for useful link
|
||||||
|
if host_ip in ("localhost", "127.0.0.1"):
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
host_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
host_ip = "127.0.0.1"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
host_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Starting ttyd {bin_path} on port {_ttyd_port} (host {host_ip})")
|
||||||
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["sshx", "--quiet"],
|
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
|
||||||
stdout=log_fh,
|
stdout=open(_ttyd_log, "w"),
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
_sshx_pid = proc.pid
|
_ttyd_pid = proc.pid
|
||||||
with open(_sshx_pid_file, "w") as pf:
|
# wait for listening
|
||||||
pf.write(str(proc.pid))
|
if not _wait_for_port(_ttyd_port, timeout=5):
|
||||||
|
|
||||||
for _ in range(30):
|
|
||||||
time.sleep(1)
|
|
||||||
link = _read_link_from_log()
|
|
||||||
if link:
|
|
||||||
_sshx_link = link
|
|
||||||
return True
|
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
|
logger.error(f"ttyd exited quickly code={proc.poll()}")
|
||||||
return False
|
return False
|
||||||
return False
|
logger.warning("ttyd port not ready but proc alive, continuing")
|
||||||
|
|
||||||
|
_ttyd_link = f"http://{host_ip}:{_ttyd_port}"
|
||||||
|
logger.info(f"ttyd running link={_ttyd_link} pid={_ttyd_pid}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start ttyd: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
@router.get("/start")
|
@router.get("/start")
|
||||||
async def start_sshx(background_tasks: BackgroundTasks):
|
async def start_ttyd(request: Request):
|
||||||
global _sshx_link, _sshx_pid
|
global _ttyd_link, _ttyd_pid, _ttyd_port
|
||||||
|
if _is_alive() and _ttyd_link:
|
||||||
if _is_alive() and _sshx_link:
|
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
||||||
return {"status": "running", "link": _sshx_link}
|
|
||||||
|
|
||||||
ok, needs_restart = _ensure_sshx()
|
|
||||||
if not ok:
|
|
||||||
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
|
|
||||||
|
|
||||||
|
# install check (no restart needed)
|
||||||
|
bin_path = _find_ttyd_bin()
|
||||||
|
if not bin_path:
|
||||||
|
# try install synchronously but non-blocking via executor
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
result = await loop.run_in_executor(None, _start_sshx_background)
|
bin_path = await loop.run_in_executor(None, _ensure_ttyd)
|
||||||
|
if not bin_path:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install ttyd (arch auto-detect). Check network to github.com"})
|
||||||
|
|
||||||
# Defer dashboard restart until AFTER the response is sent
|
host_header = request.headers.get("host", "")
|
||||||
if needs_restart:
|
loop = asyncio.get_event_loop()
|
||||||
background_tasks.add_task(_trigger_dashboard_restart)
|
result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header))
|
||||||
|
|
||||||
if result:
|
if result and _ttyd_link:
|
||||||
return {"status": "running", "link": _sshx_link}
|
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
||||||
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
|
return JSONResponse(status_code=504, content={"status": "error", "message": "ttyd did not start in time"})
|
||||||
|
|
||||||
|
# keep POST alias for backward compat with old frontend
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_ttyd_post(request: Request):
|
||||||
|
return await start_ttyd(request)
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def sshx_status():
|
async def ttyd_status():
|
||||||
global _sshx_link, _sshx_installing
|
global _ttyd_link, _ttyd_installing, _ttyd_port
|
||||||
if _is_alive() and _sshx_link:
|
if _is_alive() and _ttyd_link:
|
||||||
return {"status": "running", "link": _sshx_link}
|
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
||||||
if _sshx_installing:
|
if _ttyd_installing:
|
||||||
return {"status": "installing", "link": None}
|
return {"status": "installing", "link": None}
|
||||||
return {"status": "stopped", "link": None}
|
return {"status": "stopped", "link": None}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
async def stop_sshx():
|
async def stop_ttyd():
|
||||||
_kill_sshx()
|
_kill_ttyd()
|
||||||
return {"status": "stopped"}
|
return {"status": "stopped"}
|
||||||
|
|
||||||
|
@router.get("/stop")
|
||||||
|
async def stop_ttyd_get():
|
||||||
|
_kill_ttyd()
|
||||||
|
return {"status": "stopped"}
|
||||||
|
|
||||||
|
# deprecated - no restart needed anymore, keep for compat but do nothing
|
||||||
@router.post("/restart-dashboard")
|
@router.post("/restart-dashboard")
|
||||||
async def restart_dashboard():
|
async def restart_dashboard():
|
||||||
try:
|
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
||||||
subprocess.Popen(
|
|
||||||
["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"],
|
@router.get("/restart-dashboard")
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
async def restart_dashboard_get():
|
||||||
start_new_session=True,
|
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
||||||
)
|
|
||||||
return {"status": "restarting"}
|
|
||||||
except Exception as e:
|
|
||||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
name: sshx-link
|
name: sshx-link
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
description: Start sshx shared terminal and return the share link
|
description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart
|
||||||
author: sisyphus
|
author: sisyphus
|
||||||
provides_tools:
|
provides_tools:
|
||||||
- sshx_start
|
- sshx_start
|
||||||
|
- ttyd_start
|
||||||
provides_hooks: []
|
provides_hooks: []
|
||||||
requires_env: []
|
requires_env: []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue