2026-08-17 15:15:05 +00:00
|
|
|
import asyncio
|
|
|
|
|
import subprocess
|
|
|
|
|
import re
|
2026-08-17 22:33:19 +00:00
|
|
|
import os
|
2026-08-17 15:15:05 +00:00
|
|
|
import shutil
|
|
|
|
|
import logging
|
2026-08-17 22:33:19 +00:00
|
|
|
import time
|
2026-08-17 22:03:34 +00:00
|
|
|
from typing import Optional
|
2026-08-19 00:00:34 +00:00
|
|
|
from fastapi import APIRouter, BackgroundTasks
|
2026-08-17 15:15:05 +00:00
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
_sshx_link = None
|
2026-08-17 22:33:19 +00:00
|
|
|
_sshx_pid = None
|
2026-08-19 00:00:34 +00:00
|
|
|
_sshx_installing = False
|
2026-08-17 22:33:19 +00:00
|
|
|
_sshx_log = "/tmp/sshx_link.log"
|
2026-08-17 22:34:04 +00:00
|
|
|
_sshx_pid_file = "/tmp/sshx_link.pid"
|
2026-08-17 15:15:05 +00:00
|
|
|
|
2026-08-17 22:43:40 +00:00
|
|
|
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?")
|
2026-08-17 15:15:05 +00:00
|
|
|
|
2026-08-19 00:00:34 +00:00
|
|
|
import threading as _threading
|
2026-08-17 15:15:05 +00:00
|
|
|
|
2026-08-19 00:00:34 +00:00
|
|
|
|
|
|
|
|
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...")
|
2026-08-17 15:15:05 +00:00
|
|
|
try:
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
2026-08-17 22:30:21 +00:00
|
|
|
capture_output=True, timeout=60,
|
2026-08-17 15:15:05 +00:00
|
|
|
)
|
2026-08-17 22:41:52 +00:00
|
|
|
if shutil.which("sshx"):
|
2026-08-19 00:00:34 +00:00
|
|
|
logger.info("sshx installed successfully")
|
2026-08-17 22:41:52 +00:00
|
|
|
_trigger_dashboard_restart()
|
2026-08-19 00:00:34 +00:00
|
|
|
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
|
|
|
|
|
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
|
2026-08-17 15:15:05 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("sshx install failed: %s", e)
|
2026-08-19 00:00:34 +00:00
|
|
|
return False, False
|
2026-08-17 15:15:05 +00:00
|
|
|
|
|
|
|
|
|
2026-08-17 22:41:52 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 22:33:19 +00:00
|
|
|
def _kill_sshx():
|
|
|
|
|
global _sshx_pid, _sshx_link
|
|
|
|
|
if _sshx_pid:
|
2026-08-17 22:30:21 +00:00
|
|
|
try:
|
2026-08-17 22:33:19 +00:00
|
|
|
os.kill(_sshx_pid, 15)
|
2026-08-17 22:34:04 +00:00
|
|
|
except (ProcessLookupError, PermissionError):
|
2026-08-17 22:33:19 +00:00
|
|
|
pass
|
2026-08-17 22:34:04 +00:00
|
|
|
_sshx_pid = None
|
2026-08-17 22:33:19 +00:00
|
|
|
_sshx_link = None
|
2026-08-17 22:34:04 +00:00
|
|
|
for f in [_sshx_log, _sshx_pid_file]:
|
|
|
|
|
if os.path.exists(f):
|
|
|
|
|
os.remove(f)
|
2026-08-17 22:33:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_alive() -> bool:
|
|
|
|
|
global _sshx_pid
|
|
|
|
|
if not _sshx_pid:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
os.kill(_sshx_pid, 0)
|
|
|
|
|
return True
|
2026-08-17 22:34:04 +00:00
|
|
|
except (ProcessLookupError, PermissionError):
|
2026-08-17 22:33:19 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 22:34:04 +00:00
|
|
|
def _read_link_from_log() -> Optional[str]:
|
|
|
|
|
if not os.path.exists(_sshx_log):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with open(_sshx_log) as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
m = LINK_RE.search(content)
|
|
|
|
|
if m:
|
|
|
|
|
return m.group(0)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 22:33:19 +00:00
|
|
|
def _start_sshx_background():
|
|
|
|
|
global _sshx_pid, _sshx_link
|
|
|
|
|
_kill_sshx()
|
|
|
|
|
_sshx_link = None
|
|
|
|
|
|
2026-08-17 22:34:04 +00:00
|
|
|
log_fh = open(_sshx_log, "w")
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
["sshx", "--quiet"],
|
|
|
|
|
stdout=log_fh,
|
2026-08-17 22:33:19 +00:00
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
start_new_session=True,
|
|
|
|
|
)
|
2026-08-17 22:34:04 +00:00
|
|
|
_sshx_pid = proc.pid
|
|
|
|
|
with open(_sshx_pid_file, "w") as pf:
|
|
|
|
|
pf.write(str(proc.pid))
|
2026-08-17 22:33:19 +00:00
|
|
|
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
time.sleep(1)
|
2026-08-17 22:34:04 +00:00
|
|
|
link = _read_link_from_log()
|
|
|
|
|
if link:
|
|
|
|
|
_sshx_link = link
|
|
|
|
|
return True
|
|
|
|
|
if proc.poll() is not None:
|
|
|
|
|
return False
|
2026-08-17 22:33:19 +00:00
|
|
|
return False
|
2026-08-17 22:30:21 +00:00
|
|
|
|
|
|
|
|
|
2026-08-17 15:15:05 +00:00
|
|
|
@router.get("/start")
|
2026-08-19 00:00:34 +00:00
|
|
|
async def start_sshx(background_tasks: BackgroundTasks):
|
2026-08-17 22:33:19 +00:00
|
|
|
global _sshx_link, _sshx_pid
|
2026-08-17 15:15:05 +00:00
|
|
|
|
2026-08-17 22:33:19 +00:00
|
|
|
if _is_alive() and _sshx_link:
|
2026-08-17 15:15:05 +00:00
|
|
|
return {"status": "running", "link": _sshx_link}
|
|
|
|
|
|
2026-08-19 00:00:34 +00:00
|
|
|
ok, needs_restart = _ensure_sshx()
|
|
|
|
|
if not ok:
|
2026-08-17 22:30:21 +00:00
|
|
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
|
2026-08-17 15:15:05 +00:00
|
|
|
|
|
|
|
|
loop = asyncio.get_event_loop()
|
2026-08-17 22:33:19 +00:00
|
|
|
result = await loop.run_in_executor(None, _start_sshx_background)
|
2026-08-17 15:15:05 +00:00
|
|
|
|
2026-08-19 00:00:34 +00:00
|
|
|
# Defer dashboard restart until AFTER the response is sent
|
|
|
|
|
if needs_restart:
|
|
|
|
|
background_tasks.add_task(_trigger_dashboard_restart)
|
|
|
|
|
|
2026-08-17 22:33:19 +00:00
|
|
|
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"})
|
2026-08-17 15:15:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/status")
|
|
|
|
|
async def sshx_status():
|
2026-08-19 00:00:34 +00:00
|
|
|
global _sshx_link, _sshx_installing
|
2026-08-17 22:33:19 +00:00
|
|
|
if _is_alive() and _sshx_link:
|
2026-08-17 15:15:05 +00:00
|
|
|
return {"status": "running", "link": _sshx_link}
|
2026-08-19 00:00:34 +00:00
|
|
|
if _sshx_installing:
|
|
|
|
|
return {"status": "installing", "link": None}
|
2026-08-17 15:15:05 +00:00
|
|
|
return {"status": "stopped", "link": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/stop")
|
|
|
|
|
async def stop_sshx():
|
2026-08-17 22:33:19 +00:00
|
|
|
_kill_sshx()
|
|
|
|
|
return {"status": "stopped"}
|
2026-08-17 22:30:21 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-08-17 22:33:19 +00:00
|
|
|
start_new_session=True,
|
2026-08-17 22:30:21 +00:00
|
|
|
)
|
|
|
|
|
return {"status": "restarting"}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|