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
|
||||
================
|
||||
Downloads sshx, starts a shared terminal session,
|
||||
and returns the shareable link.
|
||||
ttyd-link plugin (replaces sshx)
|
||||
=================================
|
||||
Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import re
|
||||
import logging
|
||||
import shutil
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import urllib.request
|
||||
import socket
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_sshx_process = None
|
||||
_sshx_link = None
|
||||
_ttyd_process = 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:
|
||||
"""Extract sshx link from process output."""
|
||||
# sshx outputs links like: https://sshx.io/s/xxxxx
|
||||
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line)
|
||||
return match.group(0) if match else None
|
||||
# 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",
|
||||
}
|
||||
|
||||
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:
|
||||
"""Download and install sshx binary if not present."""
|
||||
if shutil.which("sshx"):
|
||||
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
||||
return True
|
||||
def _get_ttyd_url() -> str:
|
||||
arch = _detect_arch()
|
||||
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
|
||||
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
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...")
|
||||
# cache copy
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and shutil.which("sshx"):
|
||||
logger.info("sshx installed successfully")
|
||||
else:
|
||||
logger.error("sshx install failed: %s", result.stderr)
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"ttyd installed successfully to {dest}")
|
||||
return dest
|
||||
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):
|
||||
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(
|
||||
name="sshx_start",
|
||||
toolset="sshx-link",
|
||||
schema={
|
||||
"description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
handler=_run_sshx,
|
||||
handler=_run_ttyd,
|
||||
)
|
||||
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 { Card, CardHeader, CardTitle, CardContent, Button } = SDK.components;
|
||||
|
||||
const fetchJSON = SDK.fetchJSON;
|
||||
|
||||
async function safeFetchJSON(path, opts) {
|
||||
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 + ")");
|
||||
throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,120));
|
||||
}
|
||||
}
|
||||
|
||||
async function apiPost(path) {
|
||||
const res = await safeFetchJSON(path, { method: "POST" });
|
||||
return res;
|
||||
}
|
||||
|
||||
function SSHXPage() {
|
||||
function TTYDPage() {
|
||||
const [link, setLink] = useState(null);
|
||||
const [port, setPort] = useState(null);
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const startSSHX = useCallback(async function () {
|
||||
const start = useCallback(async function () {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -35,24 +29,32 @@
|
|||
setStatus("installing");
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
setError(res.message || "No link returned");
|
||||
setStatus("error");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Failed to start sshx");
|
||||
setError(err.message || "Failed to start ttyd");
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopSSHX = useCallback(async function () {
|
||||
const stop = useCallback(async function () {
|
||||
try {
|
||||
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
||||
setLink(null);
|
||||
setPort(null);
|
||||
setStatus("idle");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
|
|
@ -64,12 +66,18 @@
|
|||
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") {
|
||||
setStatus("installing");
|
||||
} else if (res.status === "stopped") {
|
||||
if (status === "running") {
|
||||
setLink(null);
|
||||
setStatus("idle");
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
}, [status]);
|
||||
|
||||
useEffect(function () {
|
||||
checkStatus();
|
||||
|
|
@ -86,13 +94,14 @@
|
|||
if (status === "installing") {
|
||||
return React.createElement(Card, 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("p", {
|
||||
className: "text-sm text-yellow-500",
|
||||
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) {
|
||||
return React.createElement(Card, 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("p", {
|
||||
className: "text-sm text-green-500",
|
||||
style: { fontWeight: 600, marginBottom: "0.5rem" }
|
||||
}, "Running"),
|
||||
React.createElement("a", {
|
||||
href: link,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
style: {
|
||||
color: "#60a5fa",
|
||||
fontSize: "1.1rem",
|
||||
fontSize: "1rem",
|
||||
wordBreak: "break-all",
|
||||
textDecoration: "underline"
|
||||
textDecoration: "underline",
|
||||
display: "block",
|
||||
marginBottom: "0.75rem"
|
||||
}
|
||||
}, link),
|
||||
React.createElement("div", { style: { marginTop: "1rem" } },
|
||||
React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } },
|
||||
React.createElement(Button, {
|
||||
onClick: stopSSHX,
|
||||
variant: "destructive"
|
||||
onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
|
||||
variant: "outline",
|
||||
size: "sm"
|
||||
}, "Copy Link"),
|
||||
React.createElement(Button, {
|
||||
onClick: stop,
|
||||
variant: "destructive",
|
||||
size: "sm"
|
||||
}, "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,
|
||||
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("p", {
|
||||
className: "text-sm text-muted-foreground",
|
||||
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", {
|
||||
className: "text-sm text-red-500",
|
||||
style: { marginBottom: "1rem" }
|
||||
style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
|
||||
}, "Error: " + error),
|
||||
React.createElement(Button, {
|
||||
onClick: startSSHX,
|
||||
onClick: start,
|
||||
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",
|
||||
"label": "SSHX",
|
||||
"description": "Start a shared terminal session and get the link",
|
||||
"label": "TTYD Terminal",
|
||||
"description": "TTYD shared terminal - auto arch, no restart",
|
||||
"icon": "Terminal",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"tab": {
|
||||
"path": "/sshx",
|
||||
"position": "end"
|
||||
|
|
|
|||
|
|
@ -1,199 +1,251 @@
|
|||
import asyncio
|
||||
import subprocess
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
import time
|
||||
import platform
|
||||
import stat
|
||||
import urllib.request
|
||||
import socket
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_sshx_link = None
|
||||
_sshx_pid = None
|
||||
_sshx_installing = False
|
||||
_sshx_log = "/tmp/sshx_link.log"
|
||||
_sshx_pid_file = "/tmp/sshx_link.pid"
|
||||
_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"
|
||||
|
||||
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():
|
||||
global _sshx_installing
|
||||
if shutil.which("sshx"):
|
||||
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
||||
return
|
||||
_sshx_installing = True
|
||||
logger.info("Auto-installing sshx...")
|
||||
def _find_ttyd_bin() -> Optional[str]:
|
||||
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() -> 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:
|
||||
subprocess.run(
|
||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
if shutil.which("sshx"):
|
||||
logger.info("sshx installed successfully")
|
||||
_trigger_dashboard_restart()
|
||||
else:
|
||||
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
|
||||
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
|
||||
try:
|
||||
subprocess.run(
|
||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
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):
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
_sshx_pid = None
|
||||
_sshx_link = None
|
||||
for f in [_sshx_log, _sshx_pid_file]:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
logger.info(f"ttyd installed to {dest}")
|
||||
return dest
|
||||
except Exception as e:
|
||||
logger.error(f"ttyd install failed: {e}")
|
||||
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:
|
||||
global _sshx_pid
|
||||
if not _sshx_pid:
|
||||
global _ttyd_pid
|
||||
if not _ttyd_pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(_sshx_pid, 0)
|
||||
os.kill(_ttyd_pid, 0)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_link_from_log() -> Optional[str]:
|
||||
if not os.path.exists(_sshx_log):
|
||||
return None
|
||||
def _kill_ttyd():
|
||||
global _ttyd_pid, _ttyd_link, _ttyd_port
|
||||
if _ttyd_pid:
|
||||
try:
|
||||
with open(_sshx_log) as f:
|
||||
content = f.read()
|
||||
m = LINK_RE.search(content)
|
||||
if m:
|
||||
return m.group(0)
|
||||
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)
|
||||
except Exception:
|
||||
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():
|
||||
global _sshx_pid, _sshx_link
|
||||
_kill_sshx()
|
||||
_sshx_link = None
|
||||
def _start_ttyd_background(host_header: str = "") -> bool:
|
||||
global _ttyd_pid, _ttyd_link, _ttyd_port
|
||||
_kill_ttyd()
|
||||
|
||||
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(
|
||||
["sshx", "--quiet"],
|
||||
stdout=log_fh,
|
||||
stderr=subprocess.DEVNULL,
|
||||
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
|
||||
stdout=open(_ttyd_log, "w"),
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
_sshx_pid = proc.pid
|
||||
with open(_sshx_pid_file, "w") as pf:
|
||||
pf.write(str(proc.pid))
|
||||
|
||||
for _ in range(30):
|
||||
time.sleep(1)
|
||||
link = _read_link_from_log()
|
||||
if link:
|
||||
_sshx_link = link
|
||||
return 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
|
||||
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")
|
||||
async def start_sshx(background_tasks: BackgroundTasks):
|
||||
global _sshx_link, _sshx_pid
|
||||
|
||||
if _is_alive() and _sshx_link:
|
||||
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"})
|
||||
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}
|
||||
|
||||
# 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()
|
||||
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
|
||||
if needs_restart:
|
||||
background_tasks.add_task(_trigger_dashboard_restart)
|
||||
host_header = request.headers.get("host", "")
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header))
|
||||
|
||||
if result:
|
||||
return {"status": "running", "link": _sshx_link}
|
||||
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
|
||||
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)
|
||||
|
||||
@router.get("/status")
|
||||
async def sshx_status():
|
||||
global _sshx_link, _sshx_installing
|
||||
if _is_alive() and _sshx_link:
|
||||
return {"status": "running", "link": _sshx_link}
|
||||
if _sshx_installing:
|
||||
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:
|
||||
return {"status": "installing", "link": None}
|
||||
return {"status": "stopped", "link": None}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_sshx():
|
||||
_kill_sshx()
|
||||
async def stop_ttyd():
|
||||
_kill_ttyd()
|
||||
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")
|
||||
async def restart_dashboard():
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["nohup", "bash", "-c", "sleep 1 && sudo systemctl restart hermes-dashboard"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"status": "restarting"}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
||||
|
||||
@router.get("/restart-dashboard")
|
||||
async def restart_dashboard_get():
|
||||
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
name: sshx-link
|
||||
version: 1.0.0
|
||||
description: Start sshx shared terminal and return the share link
|
||||
version: 1.1.0
|
||||
description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart
|
||||
author: sisyphus
|
||||
provides_tools:
|
||||
- sshx_start
|
||||
- ttyd_start
|
||||
provides_hooks: []
|
||||
requires_env: []
|
||||
|
|
|
|||
Loading…
Reference in New Issue