feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs

This commit is contained in:
Jason Staack
2026-03-14 09:05:14 -05:00
parent 7af08276ea
commit 970501e453
86 changed files with 3440 additions and 3764 deletions

View File

@@ -120,7 +120,7 @@ async def _send_email(channel: dict, alert_event: dict, device_hostname: str) ->
user=channel.get("smtp_user"),
password=smtp_password,
use_tls=channel.get("smtp_use_tls", False),
from_address=channel.get("from_address") or "alerts@mikrotik-portal.local",
from_address=channel.get("from_address") or "alerts@the-other-dude.local",
)
to = channel.get("to_address")

View File

@@ -43,7 +43,7 @@ from app.services.push_tracker import record_push, clear_push
logger = logging.getLogger(__name__)
# Name of the panic-revert scheduler installed on the RouterOS device
_PANIC_REVERT_SCHEDULER = "mikrotik-portal-panic-revert"
_PANIC_REVERT_SCHEDULER = "the-other-dude-panic-revert"
# Name of the pre-push binary backup saved on device flash
_PRE_PUSH_BACKUP = "portal-pre-push"
# Name of the RSC file used for /import on device

View File

@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
_env = SandboxedEnvironment()
# Names used on the RouterOS device during template push
_PANIC_REVERT_SCHEDULER = "mikrotik-portal-template-revert"
_PANIC_REVERT_SCHEDULER = "the-other-dude-template-revert"
_PRE_PUSH_BACKUP = "portal-template-pre-push"
_TEMPLATE_RSC = "portal-template.rsc"

View 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