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:
parent
81293d2b05
commit
51b27ed109
372
__init__.py
372
__init__.py
|
|
@ -1,9 +1,11 @@
|
||||||
"""
|
"""
|
||||||
ttyd-link plugin (replaces sshx)
|
sshx-link plugin — creates a shell at sshx.io and returns the shareable link
|
||||||
=================================
|
============================================================================
|
||||||
Share terminal via ttyd - no curl|sh, auto arch detection, no dashboard restart.
|
- Auto arch detection (x86_64 / aarch64 / armv6 / armv7)
|
||||||
|
- Downloads sshx binary from S3 without curl|sh
|
||||||
|
- Starts `sshx` and parses https://sshx.io/s/... link from stdout
|
||||||
|
- No dashboard restart needed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -12,167 +14,281 @@ import os
|
||||||
import platform
|
import platform
|
||||||
import stat
|
import stat
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import socket
|
import re
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ttyd_process = None
|
_sshx_process = None
|
||||||
_ttyd_link = None
|
_sshx_link = None
|
||||||
_ttyd_port = None
|
_sshx_log = "/tmp/sshx_link.log"
|
||||||
|
|
||||||
|
SSHX_BIN = "/tmp/sshx"
|
||||||
|
SSHX_BIN_ALT = os.path.expanduser("~/.local/bin/sshx")
|
||||||
|
SSHX_TAR = "/tmp/sshx.tar.gz"
|
||||||
|
|
||||||
|
# Reuse ttyd fallback for offline mode (optional)
|
||||||
TTYD_BIN = "/tmp/ttyd"
|
TTYD_BIN = "/tmp/ttyd"
|
||||||
TTYD_DIR = os.path.expanduser("~/.local/bin")
|
|
||||||
TTYD_BIN_ALT = os.path.join(TTYD_DIR, "ttyd")
|
|
||||||
|
|
||||||
# arch mapping for ttyd releases: https://github.com/tsl0922/ttyd/releases
|
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
|
||||||
_ARCH_MAP = {
|
|
||||||
"x86_64": "x86_64",
|
|
||||||
"amd64": "x86_64",
|
|
||||||
"aarch64": "aarch64",
|
|
||||||
"arm64": "aarch64",
|
|
||||||
"armv8l": "aarch64",
|
|
||||||
"armv7l": "arm",
|
|
||||||
"armv6l": "arm",
|
|
||||||
"arm": "arm",
|
|
||||||
"armhf": "armhf",
|
|
||||||
"i386": "i686",
|
|
||||||
"i686": "i686",
|
|
||||||
"mips": "mips",
|
|
||||||
"mips64": "mips64",
|
|
||||||
"mips64el": "mips64el",
|
|
||||||
"mipsel": "mipsel",
|
|
||||||
"s390x": "s390x",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _detect_arch() -> str:
|
|
||||||
|
def _detect_sshx_arch():
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
# handle uname -m variations
|
system = platform.system().lower()
|
||||||
arch = _ARCH_MAP.get(machine, "x86_64")
|
if system == "darwin":
|
||||||
logger.info(f"Detected machine={machine} -> ttyd arch={arch}")
|
suffix = "-apple-darwin"
|
||||||
return arch
|
if machine in ("aarch64", "arm64", "armv8l", "armv8b"):
|
||||||
|
arch = "aarch64"
|
||||||
|
elif machine in ("x86_64", "x64", "amd64"):
|
||||||
|
arch = "x86_64"
|
||||||
|
else:
|
||||||
|
arch = "aarch64" if "arm" in machine else "x86_64"
|
||||||
|
return arch, suffix
|
||||||
|
# default linux
|
||||||
|
suffix = "-unknown-linux-musl"
|
||||||
|
if machine in ("aarch64", "aarch64_be", "arm64", "armv8b", "armv8l"):
|
||||||
|
arch = "aarch64"
|
||||||
|
elif machine in ("x86_64", "x64", "amd64"):
|
||||||
|
arch = "x86_64"
|
||||||
|
elif machine == "armv6l":
|
||||||
|
arch = "arm"
|
||||||
|
suffix += "eabihf"
|
||||||
|
elif machine == "armv7l":
|
||||||
|
arch = "armv7"
|
||||||
|
suffix += "eabihf"
|
||||||
|
else:
|
||||||
|
arch = "x86_64"
|
||||||
|
return arch, suffix
|
||||||
|
|
||||||
def _get_ttyd_url() -> str:
|
|
||||||
arch = _detect_arch()
|
|
||||||
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
|
|
||||||
|
|
||||||
def _find_ttyd_bin() -> str | None:
|
def _get_sshx_url() -> str:
|
||||||
for p in [TTYD_BIN, TTYD_BIN_ALT, shutil.which("ttyd")]:
|
arch, suffix = _detect_sshx_arch()
|
||||||
|
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
|
||||||
|
logger.info(f"Detected arch={arch} suffix={suffix} -> sshx url={url}")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _find_sshx_bin() -> str | None:
|
||||||
|
for p in [SSHX_BIN, SSHX_BIN_ALT, shutil.which("sshx")]:
|
||||||
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
||||||
return p
|
return p
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _ensure_ttyd_installed() -> str | None:
|
|
||||||
"""Ensure ttyd binary exists, auto-download with arch detection. Returns bin path or None."""
|
def _ensure_sshx_installed() -> str | None:
|
||||||
existing = _find_ttyd_bin()
|
"""Ensure sshx binary exists, download + extract if needed. Returns path or None."""
|
||||||
|
existing = _find_sshx_bin()
|
||||||
if existing:
|
if existing:
|
||||||
logger.info(f"ttyd already installed at {existing}")
|
logger.info(f"sshx already installed at {existing}")
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
url = _get_ttyd_url()
|
url = _get_sshx_url()
|
||||||
dest = TTYD_BIN
|
dest = SSHX_BIN
|
||||||
# try ~/.local/bin if /tmp not writable or prefer persistence
|
os.makedirs(os.path.dirname(SSHX_BIN_ALT), exist_ok=True)
|
||||||
|
tmp_tar = SSHX_TAR
|
||||||
try:
|
try:
|
||||||
os.makedirs(TTYD_DIR, exist_ok=True)
|
logger.info(f"Downloading sshx from {url} -> {tmp_tar}")
|
||||||
# we download to /tmp first then also copy to ~/.local/bin for caching
|
urllib.request.urlretrieve(url, tmp_tar)
|
||||||
logger.info(f"Downloading ttyd from {url} -> {dest}")
|
# extract tar.gz - contains single file 'sshx'
|
||||||
urllib.request.urlretrieve(url, dest)
|
logger.info(f"Extracting {tmp_tar}")
|
||||||
|
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)
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
||||||
|
|
||||||
# cache copy
|
# cache copy
|
||||||
try:
|
try:
|
||||||
if dest != TTYD_BIN_ALT:
|
if dest != SSHX_BIN_ALT:
|
||||||
import shutil as _sh
|
shutil.copy2(dest, SSHX_BIN_ALT)
|
||||||
_sh.copy2(dest, TTYD_BIN_ALT)
|
os.chmod(SSHX_BIN_ALT, os.stat(SSHX_BIN_ALT).st_mode | stat.S_IEXEC)
|
||||||
os.chmod(TTYD_BIN_ALT, os.stat(TTYD_BIN_ALT).st_mode | stat.S_IEXEC)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"ttyd installed successfully to {dest}")
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
os.remove(tmp_tar)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"sshx installed successfully to {dest}")
|
||||||
return dest
|
return dest
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ttyd install failed from {url}: {e}")
|
logger.error(f"sshx install failed from {url}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_free_port() -> int:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.bind(("", 0))
|
|
||||||
port = s.getsockname()[1]
|
|
||||||
s.close()
|
|
||||||
return port
|
|
||||||
|
|
||||||
def _wait_for_port(port: int, timeout: int = 5) -> bool:
|
def _is_sshx_alive() -> bool:
|
||||||
for _ in range(timeout * 10):
|
global _sshx_process
|
||||||
try:
|
if _sshx_process is None:
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
time.sleep(0.1)
|
|
||||||
return False
|
return False
|
||||||
|
return _sshx_process.poll() is None
|
||||||
|
|
||||||
def _run_ttyd(tool_ctx):
|
|
||||||
"""Run ttyd in background, return link."""
|
|
||||||
global _ttyd_process, _ttyd_link, _ttyd_port
|
|
||||||
|
|
||||||
if _ttyd_process and _ttyd_process.poll() is None and _ttyd_link:
|
def _capture_link(proc, timeout=15) -> str | None:
|
||||||
tool_ctx.yield_result({"status": "already_running", "link": _ttyd_link, "port": _ttyd_port})
|
"""Read stdout until link found or timeout. Returns link or None."""
|
||||||
return
|
link = None
|
||||||
|
start = time.time()
|
||||||
bin_path = _ensure_ttyd_installed()
|
# use non-blocking read via thread? simpler: read line with timeout via poll
|
||||||
if not bin_path:
|
# We'll read via proc.stdout readline with timeout loop
|
||||||
tool_ctx.yield_result({"status": "error", "message": "Failed to install ttyd (auto arch detect failed). Check network to github.com"})
|
import select
|
||||||
return
|
|
||||||
|
|
||||||
_ttyd_port = _get_free_port()
|
|
||||||
logger.info(f"Starting ttyd on port {_ttyd_port} using {bin_path}...")
|
|
||||||
|
|
||||||
|
output = ""
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
# process died, collect remaining
|
||||||
try:
|
try:
|
||||||
_ttyd_process = subprocess.Popen(
|
rem = proc.stdout.read() or ""
|
||||||
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
|
output += rem
|
||||||
stdout=subprocess.DEVNULL,
|
except Exception:
|
||||||
stderr=subprocess.DEVNULL,
|
pass
|
||||||
|
break
|
||||||
|
# check if data available
|
||||||
|
try:
|
||||||
|
# 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_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_sshx_installed()
|
||||||
|
if not bin_path:
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx. Check network to s3.amazonaws.com/sshx"})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting sshx via {bin_path} ...")
|
||||||
|
try:
|
||||||
|
# --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,
|
stdin=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start ttyd: {e}")
|
logger.error(f"Failed to start sshx: {e}")
|
||||||
tool_ctx.yield_result({"status": "error", "message": f"Failed to start ttyd: {e}"})
|
tool_ctx.yield_result({"status": "error", "message": f"Failed to start sshx: {e}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _wait_for_port(_ttyd_port, timeout=5):
|
link = _capture_link(_sshx_process, timeout=15)
|
||||||
# process might have died
|
if link:
|
||||||
if _ttyd_process.poll() is not None:
|
_sshx_link = link
|
||||||
tool_ctx.yield_result({"status": "error", "message": "ttyd exited immediately"})
|
logger.info(f"sshx running at {_sshx_link} pid={_sshx_process.pid}")
|
||||||
return
|
tool_ctx.yield_result({"status": "running", "link": _sshx_link, "pid": _sshx_process.pid})
|
||||||
logger.warning("ttyd port not ready but continuing")
|
else:
|
||||||
|
# process may still be alive but link not captured — check if died
|
||||||
# construct link - use host detection via socket
|
if _sshx_process.poll() is not None:
|
||||||
try:
|
try:
|
||||||
hostname = socket.gethostname()
|
out = _sshx_process.stdout.read() or ""
|
||||||
# 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:
|
except Exception:
|
||||||
host_ip = "127.0.0.1"
|
out = ""
|
||||||
finally:
|
tool_ctx.yield_result({"status": "error", "message": f"sshx exited quickly (code={_sshx_process.poll()}): {out[:300]}"})
|
||||||
s.close()
|
_sshx_process = None
|
||||||
except Exception:
|
else:
|
||||||
host_ip = "127.0.0.1"
|
# 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})
|
||||||
|
|
||||||
_ttyd_link = f"http://{host_ip}:{_ttyd_port}"
|
|
||||||
logger.info(f"ttyd running at {_ttyd_link}")
|
def _stop_sshx(tool_ctx=None):
|
||||||
tool_ctx.yield_result({"status": "running", "link": _ttyd_link, "port": _ttyd_port})
|
global _sshx_process, _sshx_link
|
||||||
|
if _sshx_process and _sshx_process.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(_sshx_process.pid), 15)
|
||||||
|
except Exception:
|
||||||
|
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):
|
def register(ctx):
|
||||||
# no background auto-install that blocks - lazy install on demand
|
# warmup download in background — no restart needed
|
||||||
# but we can warm up in thread without requiring restart
|
|
||||||
def _warmup():
|
def _warmup():
|
||||||
try:
|
try:
|
||||||
if not _find_ttyd_bin():
|
if not _find_sshx_bin():
|
||||||
logger.info("Warming up ttyd install (no restart needed)...")
|
logger.info("Warming up sshx install...")
|
||||||
_ensure_ttyd_installed()
|
_ensure_sshx_installed()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"warmup failed: {e}")
|
logger.debug(f"warmup failed: {e}")
|
||||||
|
|
||||||
|
|
@ -182,20 +298,28 @@ def register(ctx):
|
||||||
name="sshx_start",
|
name="sshx_start",
|
||||||
toolset="sshx-link",
|
toolset="sshx-link",
|
||||||
schema={
|
schema={
|
||||||
"description": "Start a ttyd shared terminal session and return the shareable link. No dashboard restart needed, auto arch detection.",
|
"description": "Create a shell at sshx.io and return the shareable link. Auto arch detection, downloads sshx binary if needed.",
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
},
|
},
|
||||||
handler=_run_ttyd,
|
handler=_run_sshx,
|
||||||
)
|
)
|
||||||
# also register new name for clarity
|
ctx.register_tool(
|
||||||
|
name="sshx_stop",
|
||||||
|
toolset="sshx-link",
|
||||||
|
schema={
|
||||||
|
"description": "Stop the running sshx session",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
|
},
|
||||||
|
handler=_stop_sshx,
|
||||||
|
)
|
||||||
|
# alias for compat with old ttyd plugin
|
||||||
ctx.register_tool(
|
ctx.register_tool(
|
||||||
name="ttyd_start",
|
name="ttyd_start",
|
||||||
toolset="sshx-link",
|
toolset="sshx-link",
|
||||||
schema={
|
schema={
|
||||||
"description": "Start ttyd terminal (alias of sshx_start, auto arch, no restart)",
|
"description": "Alias of sshx_start (kept for compatibility)",
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
},
|
},
|
||||||
handler=_run_ttyd,
|
handler=_run_sshx,
|
||||||
)
|
)
|
||||||
logger.info("ttyd-link plugin registered (replaces sshx, no restart)")
|
logger.info("sshx-link plugin registered (sshx.io, auto arch, no restart)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,12 @@
|
||||||
const res = await fetch(path, opts || {});
|
const res = await fetch(path, opts || {});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
try { return JSON.parse(text); } catch (_) {
|
try { return JSON.parse(text); } catch (_) {
|
||||||
throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,120));
|
throw new Error("Server returned non-JSON (status " + res.status + "): " + text.slice(0,300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TTYDPage() {
|
function SSHXPage() {
|
||||||
const [link, setLink] = useState(null);
|
const [link, setLink] = useState(null);
|
||||||
const [port, setPort] = useState(null);
|
|
||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
|
@ -31,21 +30,23 @@
|
||||||
}
|
}
|
||||||
if (res.status === "running" && res.link) {
|
if (res.status === "running" && res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
setPort(res.port);
|
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res = await safeFetchJSON("/api/plugins/sshx-link/start");
|
res = await safeFetchJSON("/api/plugins/sshx-link/start");
|
||||||
if (res.link) {
|
if (res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
setPort(res.port || null);
|
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
|
} else if (res.status === "starting") {
|
||||||
|
setStatus("installing");
|
||||||
|
setError("sshx đang khởi động, thử lại sau 2s...");
|
||||||
|
setTimeout(function(){ safeFetchJSON("/api/plugins/sshx-link/status").then(function(r){ if(r.link){ setLink(r.link); setStatus("running"); setError(null);} }); }, 2000);
|
||||||
} else {
|
} else {
|
||||||
setError(res.message || "No link returned");
|
setError(res.message || "No link returned");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to start ttyd");
|
setError(err.message || "Failed to start sshx");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -54,8 +55,8 @@
|
||||||
try {
|
try {
|
||||||
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
||||||
setLink(null);
|
setLink(null);
|
||||||
setPort(null);
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +67,8 @@
|
||||||
const res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
const res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
||||||
if (res.status === "running" && res.link) {
|
if (res.status === "running" && res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
setPort(res.port || null);
|
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
} else if (res.status === "installing") {
|
} else if (res.status === "installing" || res.status === "starting") {
|
||||||
setStatus("installing");
|
setStatus("installing");
|
||||||
} else if (res.status === "stopped") {
|
} else if (res.status === "stopped") {
|
||||||
if (status === "running") {
|
if (status === "running") {
|
||||||
|
|
@ -94,14 +94,15 @@
|
||||||
if (status === "installing") {
|
if (status === "installing") {
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "TTYD Shared Terminal")
|
React.createElement(CardTitle, null, "sshx.io Shared Terminal")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("p", {
|
React.createElement("p", {
|
||||||
className: "text-sm text-yellow-500",
|
className: "text-sm text-yellow-500",
|
||||||
style: { fontWeight: 600 }
|
style: { fontWeight: 600 }
|
||||||
}, "Installing ttyd (auto arch detect)..."),
|
}, "Đang cài / khởi động sshx (auto arch detect)..."),
|
||||||
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Downloading binary for your arch, no restart needed.")
|
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } }, "Đang tải binary từ s3.amazonaws.com/sshx, không cần restart dashboard. Tự động thử lại sau 2s."),
|
||||||
|
error && React.createElement("p", { className: "text-xs text-yellow-700", style: { marginTop: "0.5rem"}}, error)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +110,8 @@
|
||||||
if (status === "running" && link) {
|
if (status === "running" && link) {
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "TTYD Shared Terminal"),
|
React.createElement(CardTitle, null, "sshx.io Shared Terminal"),
|
||||||
React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running - No dashboard restart needed")
|
React.createElement("p", { className: "text-xs text-green-500", style: { fontWeight: 600 } }, "● Running — shell đã tạo ở sshx.io")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("a", {
|
React.createElement("a", {
|
||||||
|
|
@ -119,19 +120,25 @@
|
||||||
rel: "noopener noreferrer",
|
rel: "noopener noreferrer",
|
||||||
style: {
|
style: {
|
||||||
color: "#60a5fa",
|
color: "#60a5fa",
|
||||||
fontSize: "1rem",
|
fontSize: "1.05rem",
|
||||||
wordBreak: "break-all",
|
wordBreak: "break-all",
|
||||||
textDecoration: "underline",
|
textDecoration: "underline",
|
||||||
display: "block",
|
display: "block",
|
||||||
marginBottom: "0.75rem"
|
marginBottom: "0.75rem",
|
||||||
|
fontWeight: 600
|
||||||
}
|
}
|
||||||
}, link),
|
}, link),
|
||||||
React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem" } },
|
React.createElement("div", { style: { display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" } },
|
||||||
React.createElement(Button, {
|
React.createElement(Button, {
|
||||||
onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
|
onClick: function() { navigator.clipboard && navigator.clipboard.writeText(link); },
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
size: "sm"
|
size: "sm"
|
||||||
}, "Copy Link"),
|
}, "Copy Link"),
|
||||||
|
React.createElement(Button, {
|
||||||
|
onClick: function() { window.open(link, "_blank"); },
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm"
|
||||||
|
}, "Open"),
|
||||||
React.createElement(Button, {
|
React.createElement(Button, {
|
||||||
onClick: stop,
|
onClick: stop,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
|
@ -143,18 +150,19 @@
|
||||||
border: "1px solid #333",
|
border: "1px solid #333",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
height: "500px",
|
height: "560px",
|
||||||
background: "#000"
|
background: "#000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
React.createElement("iframe", {
|
React.createElement("iframe", {
|
||||||
src: link,
|
src: link,
|
||||||
style: { width: "100%", height: "100%", border: "0" },
|
style: { width: "100%", height: "100%", border: "0" },
|
||||||
title: "TTYD Terminal"
|
title: "sshx Terminal",
|
||||||
|
allow: "clipboard-read; clipboard-write"
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } },
|
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.5rem" } },
|
||||||
"Terminal embedded above. Open in new tab if iframe blocked by CSP."
|
"Terminal nhúng ở trên (infinite canvas, cộng tác realtime). Nếu iframe bị chặn, bấm Open để mở tab mới. Link có dạng https://sshx.io/s/<id>#<key> — chia sẻ cho user là xong."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -162,14 +170,14 @@
|
||||||
|
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
React.createElement(CardTitle, null, "TTYD Shared Terminal"),
|
React.createElement(CardTitle, null, "sshx.io Shared Terminal"),
|
||||||
React.createElement("p", { className: "text-xs text-muted-foreground" }, "Auto arch detection, no restart required")
|
React.createElement("p", { className: "text-xs text-muted-foreground" }, "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart")
|
||||||
),
|
),
|
||||||
React.createElement(CardContent, null,
|
React.createElement(CardContent, null,
|
||||||
React.createElement("p", {
|
React.createElement("p", {
|
||||||
className: "text-sm text-muted-foreground",
|
className: "text-sm text-muted-foreground",
|
||||||
style: { marginBottom: "1rem" }
|
style: { marginBottom: "1rem" }
|
||||||
}, "Start a shared terminal via ttyd. Works offline, no dependency on sshx.io."),
|
}, "Bấm Start để tạo shell cộng tác trên sshx.io. Plugin sẽ tự tải binary sshx (S3), chạy sshx và parse link https://sshx.io/s/... trả về cho user."),
|
||||||
error && React.createElement("p", {
|
error && React.createElement("p", {
|
||||||
className: "text-sm text-red-500",
|
className: "text-sm text-red-500",
|
||||||
style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
|
style: { marginBottom: "1rem", whiteSpace: "pre-wrap" }
|
||||||
|
|
@ -177,10 +185,11 @@
|
||||||
React.createElement(Button, {
|
React.createElement(Button, {
|
||||||
onClick: start,
|
onClick: start,
|
||||||
disabled: status === "loading"
|
disabled: status === "loading"
|
||||||
}, status === "loading" ? "Starting ttyd..." : "Start TTYD")
|
}, status === "loading" ? "Đang tạo shell ở sshx.io..." : "Start sshx → Lấy link"),
|
||||||
|
React.createElement("p", { className: "text-xs text-muted-foreground", style: { marginTop: "0.75rem"}}, "Tương đương lệnh: curl -sSf https://sshx.io/get | sh -s run — nhưng tự động hoàn toàn trong dashboard.")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__HERMES_PLUGINS__.register("sshx-link", TTYDPage);
|
window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "sshx-link",
|
"name": "sshx-link",
|
||||||
"label": "TTYD Terminal",
|
"label": "sshx.io Terminal",
|
||||||
"description": "TTYD shared terminal - auto arch, no restart",
|
"description": "Tạo shell ở sshx.io và trả link chia sẻ — auto arch, không cần restart",
|
||||||
"icon": "Terminal",
|
"icon": "Terminal",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"tab": {
|
"tab": {
|
||||||
"path": "/sshx",
|
"path": "/sshx",
|
||||||
"position": "end"
|
"position": "end"
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@ import time
|
||||||
import platform
|
import platform
|
||||||
import stat
|
import stat
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import socket
|
import re
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
|
import select
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
@ -15,237 +18,289 @@ from fastapi.responses import JSONResponse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ttyd_link = None
|
_sshx_link = None
|
||||||
_ttyd_pid = None
|
_sshx_process: Optional[subprocess.Popen] = None
|
||||||
_ttyd_port = None
|
_sshx_installing = False
|
||||||
_ttyd_installing = False
|
_sshx_bin = "/tmp/sshx"
|
||||||
_ttyd_bin = "/tmp/ttyd"
|
_sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx")
|
||||||
_ttyd_bin_alt = os.path.expanduser("~/.local/bin/ttyd")
|
_sshx_tar = "/tmp/sshx.tar.gz"
|
||||||
_ttyd_log = "/tmp/ttyd_link.log"
|
|
||||||
|
|
||||||
_ARCH_MAP = {
|
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
|
||||||
"x86_64": "x86_64",
|
|
||||||
"amd64": "x86_64",
|
|
||||||
"aarch64": "aarch64",
|
|
||||||
"arm64": "aarch64",
|
|
||||||
"armv8l": "aarch64",
|
|
||||||
"armv7l": "arm",
|
|
||||||
"armv6l": "arm",
|
|
||||||
"arm": "arm",
|
|
||||||
"armhf": "armhf",
|
|
||||||
"i386": "i686",
|
|
||||||
"i686": "i686",
|
|
||||||
"mips": "mips",
|
|
||||||
"mips64": "mips64",
|
|
||||||
"mips64el": "mips64el",
|
|
||||||
"mipsel": "mipsel",
|
|
||||||
"s390x": "s390x",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _detect_arch() -> str:
|
|
||||||
|
def _detect_sshx_arch():
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
arch = _ARCH_MAP.get(machine, "x86_64")
|
system = platform.system().lower()
|
||||||
logger.info(f"Detected arch machine={machine} -> ttyd arch={arch}")
|
if system == "darwin":
|
||||||
return arch
|
suffix = "-apple-darwin"
|
||||||
|
if machine in ("aarch64", "arm64", "armv8l", "armv8b"):
|
||||||
|
arch = "aarch64"
|
||||||
|
elif machine in ("x86_64", "x64", "amd64"):
|
||||||
|
arch = "x86_64"
|
||||||
|
else:
|
||||||
|
arch = "aarch64" if "arm" in machine else "x86_64"
|
||||||
|
return arch, suffix
|
||||||
|
suffix = "-unknown-linux-musl"
|
||||||
|
if machine in ("aarch64", "aarch64_be", "arm64", "armv8b", "armv8l"):
|
||||||
|
arch = "aarch64"
|
||||||
|
elif machine in ("x86_64", "x64", "amd64"):
|
||||||
|
arch = "x86_64"
|
||||||
|
elif machine == "armv6l":
|
||||||
|
arch = "arm"
|
||||||
|
suffix += "eabihf"
|
||||||
|
elif machine == "armv7l":
|
||||||
|
arch = "armv7"
|
||||||
|
suffix += "eabihf"
|
||||||
|
else:
|
||||||
|
arch = "x86_64"
|
||||||
|
return arch, suffix
|
||||||
|
|
||||||
def _get_ttyd_url() -> str:
|
|
||||||
arch = _detect_arch()
|
|
||||||
return f"https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.{arch}"
|
|
||||||
|
|
||||||
def _find_ttyd_bin() -> Optional[str]:
|
def _get_sshx_url() -> str:
|
||||||
for p in [_ttyd_bin, _ttyd_bin_alt, shutil.which("ttyd")]:
|
arch, suffix = _detect_sshx_arch()
|
||||||
|
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
|
||||||
|
logger.info(f"sshx arch={arch} suffix={suffix} url={url}")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _find_sshx_bin() -> Optional[str]:
|
||||||
|
for p in [_sshx_bin, _sshx_bin_alt, shutil.which("sshx")]:
|
||||||
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
||||||
return p
|
return p
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _ensure_ttyd() -> Optional[str]:
|
|
||||||
"""Ensure ttyd exists, download with arch detection if needed. Returns bin path."""
|
def _ensure_sshx() -> Optional[str]:
|
||||||
existing = _find_ttyd_bin()
|
existing = _find_sshx_bin()
|
||||||
if existing:
|
if existing:
|
||||||
return existing
|
return existing
|
||||||
global _ttyd_installing
|
global _sshx_installing
|
||||||
_ttyd_installing = True
|
_sshx_installing = True
|
||||||
try:
|
try:
|
||||||
url = _get_ttyd_url()
|
url = _get_sshx_url()
|
||||||
dest = _ttyd_bin
|
dest = _sshx_bin
|
||||||
os.makedirs(os.path.dirname(_ttyd_bin_alt), exist_ok=True)
|
os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True)
|
||||||
logger.info(f"Downloading ttyd {url} -> {dest}")
|
tmp_tar = _sshx_tar
|
||||||
# urllib no curl|sh, no dashboard restart needed
|
logger.info(f"Downloading sshx {url} -> {tmp_tar}")
|
||||||
urllib.request.urlretrieve(url, dest)
|
urllib.request.urlretrieve(url, tmp_tar)
|
||||||
|
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)
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
||||||
# cache
|
|
||||||
try:
|
try:
|
||||||
if dest != _ttyd_bin_alt:
|
if dest != _sshx_bin_alt:
|
||||||
shutil.copy2(dest, _ttyd_bin_alt)
|
shutil.copy2(dest, _sshx_bin_alt)
|
||||||
os.chmod(_ttyd_bin_alt, os.stat(_ttyd_bin_alt).st_mode | stat.S_IEXEC)
|
os.chmod(_sshx_bin_alt, os.stat(_sshx_bin_alt).st_mode | stat.S_IEXEC)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"ttyd installed to {dest}")
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
os.remove(tmp_tar)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"sshx installed to {dest}")
|
||||||
return dest
|
return dest
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ttyd install failed: {e}")
|
logger.error(f"sshx install failed: {e}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
_ttyd_installing = False
|
_sshx_installing = False
|
||||||
|
|
||||||
def _get_free_port() -> int:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.bind(("", 0))
|
|
||||||
port = s.getsockname()[1]
|
|
||||||
s.close()
|
|
||||||
return port
|
|
||||||
|
|
||||||
def _is_alive() -> bool:
|
def _is_alive() -> bool:
|
||||||
global _ttyd_pid
|
global _sshx_process
|
||||||
if not _ttyd_pid:
|
if not _sshx_process:
|
||||||
return False
|
|
||||||
try:
|
|
||||||
os.kill(_ttyd_pid, 0)
|
|
||||||
return True
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
return False
|
return False
|
||||||
|
return _sshx_process.poll() is None
|
||||||
|
|
||||||
def _kill_ttyd():
|
|
||||||
global _ttyd_pid, _ttyd_link, _ttyd_port
|
def _kill_sshx():
|
||||||
if _ttyd_pid:
|
global _sshx_process, _sshx_link
|
||||||
|
if _sshx_process and _sshx_process.poll() is None:
|
||||||
try:
|
try:
|
||||||
os.kill(_ttyd_pid, 15)
|
os.killpg(os.getpgid(_sshx_process.pid), 15)
|
||||||
time.sleep(0.3)
|
except Exception:
|
||||||
# force kill if still alive
|
|
||||||
try:
|
try:
|
||||||
os.kill(_ttyd_pid, 0)
|
_sshx_process.terminate()
|
||||||
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
|
||||||
|
time.sleep(0.5)
|
||||||
def _wait_for_port(port: int, timeout: int = 5) -> bool:
|
if _sshx_process.poll() is None:
|
||||||
for _ in range(timeout * 10):
|
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
os.killpg(os.getpgid(_sshx_process.pid), 9)
|
||||||
return True
|
except Exception:
|
||||||
except OSError:
|
try:
|
||||||
|
_sshx_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_sshx_process = None
|
||||||
|
_sshx_link = None
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
r, _, _ = select.select([proc.stdout], [], [], 0.5)
|
||||||
|
if r:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
return False
|
continue
|
||||||
|
output += line
|
||||||
|
logger.info(f"sshx stdout: {line.strip()}")
|
||||||
|
m = LINK_RE.search(line)
|
||||||
|
if m:
|
||||||
|
link = m.group(0)
|
||||||
|
break
|
||||||
|
m2 = LINK_RE.search(output)
|
||||||
|
if m2:
|
||||||
|
link = m2.group(0)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"capture error: {e}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not link:
|
||||||
|
m = LINK_RE.search(output)
|
||||||
|
if m:
|
||||||
|
link = m.group(0)
|
||||||
|
if not link:
|
||||||
|
logger.warning(f"sshx link not found after {timeout}s, output: {output[:600]}")
|
||||||
|
return link
|
||||||
|
|
||||||
def _start_ttyd_background(host_header: str = "") -> bool:
|
|
||||||
global _ttyd_pid, _ttyd_link, _ttyd_port
|
|
||||||
_kill_ttyd()
|
|
||||||
|
|
||||||
bin_path = _ensure_ttyd()
|
def _start_sshx_background() -> bool:
|
||||||
|
global _sshx_process, _sshx_link
|
||||||
|
_kill_sshx()
|
||||||
|
bin_path = _ensure_sshx()
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
return False
|
return False
|
||||||
|
logger.info(f"Starting sshx {bin_path}")
|
||||||
_ttyd_port = _get_free_port()
|
|
||||||
# derive host for link: use Host header if available, else local IP
|
|
||||||
host_ip = "127.0.0.1"
|
|
||||||
if host_header:
|
|
||||||
# Host header may be "dashboard.example.com:8000" -> take hostname
|
|
||||||
host_ip = host_header.split(":")[0]
|
|
||||||
# if host is localhost, try to get outward IP for useful link
|
|
||||||
if host_ip in ("localhost", "127.0.0.1"):
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(("8.8.8.8", 80))
|
|
||||||
host_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
except Exception:
|
|
||||||
host_ip = "127.0.0.1"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect(("8.8.8.8", 80))
|
|
||||||
host_ip = s.getsockname()[0]
|
|
||||||
s.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(f"Starting ttyd {bin_path} on port {_ttyd_port} (host {host_ip})")
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[bin_path, "-p", str(_ttyd_port), "-t", "fontSize=14", "bash"],
|
[bin_path, "--quiet"],
|
||||||
stdout=open(_ttyd_log, "w"),
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
_ttyd_pid = proc.pid
|
_sshx_process = proc
|
||||||
# wait for listening
|
link = _capture_link_sync(proc, timeout=15)
|
||||||
if not _wait_for_port(_ttyd_port, timeout=5):
|
if link:
|
||||||
if proc.poll() is not None:
|
_sshx_link = link
|
||||||
logger.error(f"ttyd exited quickly code={proc.poll()}")
|
logger.info(f"sshx running link={_sshx_link} pid={proc.pid}")
|
||||||
return False
|
|
||||||
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
|
return True
|
||||||
except Exception as e:
|
# if link not found but process still alive, keep it and return false to let caller handle
|
||||||
logger.error(f"Failed to start ttyd: {e}")
|
if proc.poll() is None:
|
||||||
|
logger.warning("sshx started but link not parsed yet")
|
||||||
return False
|
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 sshx: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/start")
|
@router.get("/start")
|
||||||
async def start_ttyd(request: Request):
|
async def start_sshx(request: Request):
|
||||||
global _ttyd_link, _ttyd_pid, _ttyd_port
|
global _sshx_link, _sshx_process
|
||||||
if _is_alive() and _ttyd_link:
|
if _is_alive() and _sshx_link:
|
||||||
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
|
||||||
|
|
||||||
# install check (no restart needed)
|
bin_path = _find_sshx_bin()
|
||||||
bin_path = _find_ttyd_bin()
|
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
# try install synchronously but non-blocking via executor
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
bin_path = await loop.run_in_executor(None, _ensure_ttyd)
|
bin_path = await loop.run_in_executor(None, _ensure_sshx)
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install ttyd (arch auto-detect). Check network to github.com"})
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com"})
|
||||||
|
|
||||||
host_header = request.headers.get("host", "")
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
result = await loop.run_in_executor(None, lambda: _start_ttyd_background(host_header))
|
ok = await loop.run_in_executor(None, _start_sshx_background)
|
||||||
|
|
||||||
|
if ok and _sshx_link:
|
||||||
|
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
|
||||||
|
# if process alive but link not yet, try one more check
|
||||||
|
if _is_alive():
|
||||||
|
return JSONResponse(status_code=202, content={"status": "starting", "message": "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None})
|
||||||
|
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not start in time, check logs"})
|
||||||
|
|
||||||
if result and _ttyd_link:
|
|
||||||
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
|
||||||
return JSONResponse(status_code=504, content={"status": "error", "message": "ttyd did not start in time"})
|
|
||||||
|
|
||||||
# keep POST alias for backward compat with old frontend
|
|
||||||
@router.post("/start")
|
@router.post("/start")
|
||||||
async def start_ttyd_post(request: Request):
|
async def start_sshx_post(request: Request):
|
||||||
return await start_ttyd(request)
|
return await start_sshx(request)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def ttyd_status():
|
async def sshx_status():
|
||||||
global _ttyd_link, _ttyd_installing, _ttyd_port
|
global _sshx_link, _sshx_installing, _sshx_process
|
||||||
if _is_alive() and _ttyd_link:
|
if _is_alive() and _sshx_link:
|
||||||
return {"status": "running", "link": _ttyd_link, "port": _ttyd_port}
|
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid}
|
||||||
if _ttyd_installing:
|
if _sshx_installing:
|
||||||
return {"status": "installing", "link": None}
|
return {"status": "installing", "link": None}
|
||||||
|
if _is_alive() and not _sshx_link:
|
||||||
|
return {"status": "starting", "link": None, "pid": _sshx_process.pid}
|
||||||
return {"status": "stopped", "link": None}
|
return {"status": "stopped", "link": None}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
async def stop_ttyd():
|
async def stop_sshx():
|
||||||
_kill_ttyd()
|
_kill_sshx()
|
||||||
return {"status": "stopped"}
|
return {"status": "stopped"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stop")
|
@router.get("/stop")
|
||||||
async def stop_ttyd_get():
|
async def stop_sshx_get():
|
||||||
_kill_ttyd()
|
_kill_sshx()
|
||||||
return {"status": "stopped"}
|
return {"status": "stopped"}
|
||||||
|
|
||||||
# deprecated - no restart needed anymore, keep for compat but do nothing
|
|
||||||
@router.post("/restart-dashboard")
|
|
||||||
async def restart_dashboard():
|
|
||||||
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
|
||||||
|
|
||||||
|
# compat: old ttyd endpoint still works but now maps to sshx
|
||||||
@router.get("/restart-dashboard")
|
@router.get("/restart-dashboard")
|
||||||
async def restart_dashboard_get():
|
async def restart_dashboard():
|
||||||
return {"status": "deprecated", "message": "No restart needed with ttyd. This endpoint is no-op."}
|
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restart-dashboard")
|
||||||
|
async def restart_dashboard_post():
|
||||||
|
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
name: sshx-link
|
name: sshx-link
|
||||||
version: 1.1.0
|
version: 2.0.0
|
||||||
description: TTYD shared terminal (replaces sshx) - auto arch detection, no curl|sh, no dashboard restart
|
description: Tạo shell ở sshx.io và trả link chia sẻ — auto arch detection, không cần restart dashboard
|
||||||
author: sisyphus
|
author: sisyphus
|
||||||
provides_tools:
|
provides_tools:
|
||||||
- sshx_start
|
- sshx_start
|
||||||
|
- sshx_stop
|
||||||
- ttyd_start
|
- ttyd_start
|
||||||
provides_hooks: []
|
provides_hooks: []
|
||||||
requires_env: []
|
requires_env: []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue