feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs
This commit is contained in:
126
backend/app/services/winbox_remote.py
Normal file
126
backend/app/services/winbox_remote.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""HTTP client for the winbox-worker container.
|
||||
|
||||
Provides async helpers to create, terminate, query, and health-check
|
||||
Remote WinBox (Xpra) sessions running inside the worker container.
|
||||
All communication uses the internal Docker network.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKER_BASE_URL = "http://tod_winbox_worker:9090"
|
||||
_HEADERS = {"X-Internal-Service": "api"}
|
||||
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
||||
|
||||
|
||||
class WorkerCapacityError(Exception):
|
||||
"""Worker has no capacity for new sessions."""
|
||||
|
||||
|
||||
class WorkerLaunchError(Exception):
|
||||
"""Worker failed to launch a session."""
|
||||
|
||||
|
||||
async def create_session(
|
||||
session_id: str,
|
||||
device_ip: str,
|
||||
device_port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
idle_timeout_seconds: int,
|
||||
max_lifetime_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /sessions — ask the worker to launch an Xpra+WinBox session.
|
||||
|
||||
Credentials are zeroed from locals after the request is sent.
|
||||
Raises WorkerCapacityError (503) or WorkerLaunchError on failure.
|
||||
"""
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"tunnel_host": device_ip,
|
||||
"tunnel_port": device_port,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"idle_timeout_seconds": idle_timeout_seconds,
|
||||
"max_lifetime_seconds": max_lifetime_seconds,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=WORKER_BASE_URL, headers=_HEADERS, timeout=_TIMEOUT
|
||||
) as client:
|
||||
resp = await client.post("/sessions", json=payload)
|
||||
finally:
|
||||
# Zero credentials in the payload dict
|
||||
payload["username"] = ""
|
||||
payload["password"] = ""
|
||||
del username, password # noqa: F821 — local unbind
|
||||
|
||||
if resp.status_code == 503:
|
||||
raise WorkerCapacityError(resp.text)
|
||||
if resp.status_code >= 400:
|
||||
raise WorkerLaunchError(f"Worker returned {resp.status_code}: {resp.text}")
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def terminate_session(session_id: str) -> bool:
|
||||
"""DELETE /sessions/{session_id} — idempotent (ignores 404).
|
||||
|
||||
Returns True if the worker acknowledged termination, False if 404.
|
||||
"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=WORKER_BASE_URL, headers=_HEADERS, timeout=_TIMEOUT
|
||||
) as client:
|
||||
resp = await client.delete(f"/sessions/{session_id}")
|
||||
|
||||
if resp.status_code == 404:
|
||||
return False
|
||||
if resp.status_code >= 400:
|
||||
logger.error("Worker terminate error %s: %s", resp.status_code, resp.text)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def get_session(session_id: str) -> Optional[dict[str, Any]]:
|
||||
"""GET /sessions/{session_id} — returns None if 404."""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=WORKER_BASE_URL, headers=_HEADERS, timeout=_TIMEOUT
|
||||
) as client:
|
||||
resp = await client.get(f"/sessions/{session_id}")
|
||||
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if resp.status_code >= 400:
|
||||
logger.error("Worker get_session error %s: %s", resp.status_code, resp.text)
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def list_sessions() -> list[dict[str, Any]]:
|
||||
"""GET /sessions — return all sessions known to the worker."""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=WORKER_BASE_URL, headers=_HEADERS, timeout=_TIMEOUT
|
||||
) as client:
|
||||
resp = await client.get("/sessions")
|
||||
|
||||
if resp.status_code >= 400:
|
||||
logger.error("Worker list_sessions error %s: %s", resp.status_code, resp.text)
|
||||
return []
|
||||
data = resp.json()
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
async def health_check() -> bool:
|
||||
"""GET /healthz — returns True if the worker is healthy."""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=WORKER_BASE_URL, headers=_HEADERS, timeout=httpx.Timeout(5.0)
|
||||
) as client:
|
||||
resp = await client.get("/healthz")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
Reference in New Issue
Block a user