feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
165
backend/app/services/routeros_proxy.py
Normal file
165
backend/app/services/routeros_proxy.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""RouterOS command proxy via NATS request-reply.
|
||||
|
||||
Sends command requests to the Go poller's CmdResponder subscription
|
||||
(device.cmd.{device_id}) and returns structured RouterOS API response data.
|
||||
|
||||
Used by:
|
||||
- Config editor API (browse menu paths, add/edit/delete entries)
|
||||
- Template push service (execute rendered template commands)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import nats
|
||||
import nats.aio.client
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level NATS connection (lazy initialized)
|
||||
_nc: nats.aio.client.Client | None = None
|
||||
|
||||
|
||||
async def _get_nats() -> nats.aio.client.Client:
|
||||
"""Get or create a NATS connection for command proxy requests."""
|
||||
global _nc
|
||||
if _nc is None or _nc.is_closed:
|
||||
_nc = await nats.connect(settings.NATS_URL)
|
||||
logger.info("RouterOS proxy NATS connection established")
|
||||
return _nc
|
||||
|
||||
|
||||
async def execute_command(
|
||||
device_id: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a RouterOS API command on a device via the Go poller.
|
||||
|
||||
Args:
|
||||
device_id: UUID string of the target device.
|
||||
command: Full RouterOS API path, e.g. "/ip/address/print".
|
||||
args: Optional list of RouterOS API args, e.g. ["=.proplist=.id,address"].
|
||||
timeout: NATS request timeout in seconds (default 15s).
|
||||
|
||||
Returns:
|
||||
{"success": bool, "data": list[dict], "error": str|None}
|
||||
"""
|
||||
nc = await _get_nats()
|
||||
request = {
|
||||
"device_id": device_id,
|
||||
"command": command,
|
||||
"args": args or [],
|
||||
}
|
||||
|
||||
try:
|
||||
reply = await nc.request(
|
||||
f"device.cmd.{device_id}",
|
||||
json.dumps(request).encode(),
|
||||
timeout=timeout,
|
||||
)
|
||||
return json.loads(reply.data)
|
||||
except nats.errors.TimeoutError:
|
||||
return {
|
||||
"success": False,
|
||||
"data": [],
|
||||
"error": "Device command timed out — device may be offline or unreachable",
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("NATS request failed for device %s: %s", device_id, exc)
|
||||
return {"success": False, "data": [], "error": str(exc)}
|
||||
|
||||
|
||||
async def browse_menu(device_id: str, path: str) -> dict[str, Any]:
|
||||
"""Browse a RouterOS menu path and return all entries.
|
||||
|
||||
Args:
|
||||
device_id: Device UUID string.
|
||||
path: RouterOS menu path, e.g. "/ip/address" or "/interface".
|
||||
|
||||
Returns:
|
||||
{"success": bool, "data": list[dict], "error": str|None}
|
||||
"""
|
||||
command = f"{path}/print"
|
||||
return await execute_command(device_id, command)
|
||||
|
||||
|
||||
async def add_entry(
|
||||
device_id: str, path: str, properties: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new entry to a RouterOS menu path.
|
||||
|
||||
Args:
|
||||
device_id: Device UUID.
|
||||
path: Menu path, e.g. "/ip/address".
|
||||
properties: Key-value pairs for the new entry.
|
||||
|
||||
Returns:
|
||||
Command response dict.
|
||||
"""
|
||||
args = [f"={k}={v}" for k, v in properties.items()]
|
||||
return await execute_command(device_id, f"{path}/add", args)
|
||||
|
||||
|
||||
async def update_entry(
|
||||
device_id: str, path: str, entry_id: str | None, properties: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing entry in a RouterOS menu path.
|
||||
|
||||
Args:
|
||||
device_id: Device UUID.
|
||||
path: Menu path.
|
||||
entry_id: RouterOS .id value (e.g. "*1"). None for singleton paths.
|
||||
properties: Key-value pairs to update.
|
||||
|
||||
Returns:
|
||||
Command response dict.
|
||||
"""
|
||||
id_args = [f"=.id={entry_id}"] if entry_id else []
|
||||
args = id_args + [f"={k}={v}" for k, v in properties.items()]
|
||||
return await execute_command(device_id, f"{path}/set", args)
|
||||
|
||||
|
||||
async def remove_entry(
|
||||
device_id: str, path: str, entry_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Remove an entry from a RouterOS menu path.
|
||||
|
||||
Args:
|
||||
device_id: Device UUID.
|
||||
path: Menu path.
|
||||
entry_id: RouterOS .id value.
|
||||
|
||||
Returns:
|
||||
Command response dict.
|
||||
"""
|
||||
return await execute_command(device_id, f"{path}/remove", [f"=.id={entry_id}"])
|
||||
|
||||
|
||||
async def execute_cli(device_id: str, cli_command: str) -> dict[str, Any]:
|
||||
"""Execute an arbitrary RouterOS CLI command.
|
||||
|
||||
For commands that don't follow the standard /path/action pattern.
|
||||
The command is sent as-is to the RouterOS API.
|
||||
|
||||
Args:
|
||||
device_id: Device UUID.
|
||||
cli_command: Full CLI command string.
|
||||
|
||||
Returns:
|
||||
Command response dict.
|
||||
"""
|
||||
return await execute_command(device_id, cli_command)
|
||||
|
||||
|
||||
async def close() -> None:
|
||||
"""Close the NATS connection. Called on application shutdown."""
|
||||
global _nc
|
||||
if _nc and not _nc.is_closed:
|
||||
await _nc.drain()
|
||||
_nc = None
|
||||
logger.info("RouterOS proxy NATS connection closed")
|
||||
Reference in New Issue
Block a user