fix: keep sshx output reader alive after link, add restart-dashboard endpoint

This commit is contained in:
OpenCode Agent 2026-08-17 22:30:21 +00:00
parent dbaa655a9e
commit 4111590b18
1 changed files with 49 additions and 55 deletions

View File

@ -1,14 +1,9 @@
"""
sshx-link dashboard backend
===========================
FastAPI routes for starting sshx and returning the share link.
"""
import asyncio
import subprocess
import re
import shutil
import logging
import threading
from typing import Optional
from fastapi import APIRouter
from fastapi.responses import JSONResponse
@ -16,26 +11,19 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# Keep track of running sshx process
_sshx_process = None
_sshx_link = None
def _find_link(text: str) -> Optional[str]:
"""Extract sshx link from output."""
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", text)
return match.group(0) if match else None
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+")
def _ensure_sshx() -> bool:
"""Install sshx if not present."""
if shutil.which("sshx"):
return True
try:
subprocess.run(
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
capture_output=True,
timeout=60,
capture_output=True, timeout=60,
)
return shutil.which("sshx") is not None
except Exception as e:
@ -43,28 +31,49 @@ def _ensure_sshx() -> bool:
return False
def _start_reader(proc, future):
global _sshx_link
def _read():
try:
found = False
for line in proc.stdout:
line = line.strip()
if not found:
m = LINK_RE.search(line)
if m:
_sshx_link = m.group(0)
found = True
if not future.done():
future.set_result(_sshx_link)
if not found and not future.done():
future.set_exception(RuntimeError("sshx exited without link"))
except Exception as e:
if not future.done():
future.set_exception(e)
t = threading.Thread(target=_read, daemon=True)
t.start()
return t
@router.get("/start")
async def start_sshx():
"""Start sshx serve and return the share link."""
global _sshx_process, _sshx_link
# Already running
if _sshx_process and _sshx_process.poll() is None and _sshx_link:
return {"status": "running", "link": _sshx_link}
# Install if needed
if not _ensure_sshx():
return JSONResponse(
status_code=500,
content={"status": "error", "message": "Failed to install sshx"},
)
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
# Kill old process if any
if _sshx_process and _sshx_process.poll() is None:
_sshx_process.terminate()
try:
_sshx_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_sshx_process.kill()
# Start sshx serve
_sshx_process = subprocess.Popen(
["sshx", "--quiet"],
stdout=subprocess.PIPE,
@ -74,47 +83,22 @@ async def start_sshx():
)
_sshx_link = None
# Read output in background thread, await link
loop = asyncio.get_event_loop()
link_future = loop.create_future()
def _reader():
global _sshx_link
for line in _sshx_process.stdout:
line = line.strip()
link = _find_link(line)
if link:
_sshx_link = link
if not link_future.done():
link_future.set_result(link)
return
if not link_future.done():
link_future.set_exception(RuntimeError("sshx exited without link"))
import threading
threading.Thread(target=_reader, daemon=True).start()
_start_reader(_sshx_process, link_future)
try:
link = await asyncio.wait_for(link_future, timeout=30)
return {"status": "running", "link": link}
except asyncio.TimeoutError:
return JSONResponse(
status_code=504,
content={"status": "error", "message": "sshx did not return a link in time"},
)
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
except Exception as e:
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)},
)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/status")
async def sshx_status():
"""Check if sshx is running and return the link."""
global _sshx_process, _sshx_link
if _sshx_process and _sshx_process.poll() is None and _sshx_link:
return {"status": "running", "link": _sshx_link}
return {"status": "stopped", "link": None}
@ -122,9 +106,7 @@ async def sshx_status():
@router.post("/stop")
async def stop_sshx():
"""Stop the running sshx process."""
global _sshx_process, _sshx_link
if _sshx_process and _sshx_process.poll() is None:
_sshx_process.terminate()
try:
@ -135,3 +117,15 @@ async def stop_sshx():
_sshx_link = None
return {"status": "stopped"}
return {"status": "already_stopped"}
@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,
)
return {"status": "restarting"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})