mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 07:41:58 -06:00
Port core API routes for sites and devices
This commit is contained in:
301
Data/Engine/interfaces/http/devices.py
Normal file
301
Data/Engine/interfaces/http/devices.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from flask import Blueprint, Flask, current_app, jsonify, request, session
|
||||
|
||||
from Data.Engine.services.container import EngineServiceContainer
|
||||
from Data.Engine.services.devices import RemoteDeviceError
|
||||
|
||||
blueprint = Blueprint("engine_devices", __name__)
|
||||
|
||||
|
||||
def register(app: Flask, _services: EngineServiceContainer) -> None:
|
||||
if "engine_devices" not in app.blueprints:
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
|
||||
def _services() -> EngineServiceContainer:
|
||||
services = current_app.extensions.get("engine_services")
|
||||
if services is None: # pragma: no cover - defensive
|
||||
raise RuntimeError("engine services not initialized")
|
||||
return services
|
||||
|
||||
|
||||
def _inventory():
|
||||
return _services().device_inventory
|
||||
|
||||
|
||||
def _views():
|
||||
return _services().device_view_service
|
||||
|
||||
|
||||
def _require_admin():
|
||||
username = session.get("username")
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not isinstance(username, str) or not username:
|
||||
return jsonify({"error": "not_authenticated"}), 401
|
||||
if role != "admin":
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
return None
|
||||
|
||||
|
||||
def _is_internal_request(req: request) -> bool:
|
||||
remote = (req.remote_addr or "").strip()
|
||||
if not remote:
|
||||
return False
|
||||
try:
|
||||
return ip_address(remote).is_loopback
|
||||
except ValueError:
|
||||
return remote in {"localhost"}
|
||||
|
||||
|
||||
@blueprint.route("/api/devices", methods=["GET"])
|
||||
def list_devices() -> object:
|
||||
devices = _inventory().list_devices()
|
||||
return jsonify({"devices": devices})
|
||||
|
||||
|
||||
@blueprint.route("/api/devices/<guid>", methods=["GET"])
|
||||
def get_device_by_guid(guid: str) -> object:
|
||||
device = _inventory().get_device_by_guid(guid)
|
||||
if not device:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(device)
|
||||
|
||||
|
||||
@blueprint.route("/api/agent_devices", methods=["GET"])
|
||||
def list_agent_devices() -> object:
|
||||
guard = _require_admin()
|
||||
if guard:
|
||||
return guard
|
||||
devices = _inventory().list_agent_devices()
|
||||
return jsonify({"devices": devices})
|
||||
|
||||
|
||||
@blueprint.route("/api/ssh_devices", methods=["GET", "POST"])
|
||||
def ssh_devices() -> object:
|
||||
return _remote_devices_endpoint("ssh")
|
||||
|
||||
|
||||
@blueprint.route("/api/winrm_devices", methods=["GET", "POST"])
|
||||
def winrm_devices() -> object:
|
||||
return _remote_devices_endpoint("winrm")
|
||||
|
||||
|
||||
@blueprint.route("/api/ssh_devices/<hostname>", methods=["PUT", "DELETE"])
|
||||
def ssh_device_detail(hostname: str) -> object:
|
||||
return _remote_device_detail("ssh", hostname)
|
||||
|
||||
|
||||
@blueprint.route("/api/winrm_devices/<hostname>", methods=["PUT", "DELETE"])
|
||||
def winrm_device_detail(hostname: str) -> object:
|
||||
return _remote_device_detail("winrm", hostname)
|
||||
|
||||
|
||||
@blueprint.route("/api/agent/hash_list", methods=["GET"])
|
||||
def agent_hash_list() -> object:
|
||||
if not _is_internal_request(request):
|
||||
remote_addr = (request.remote_addr or "unknown").strip() or "unknown"
|
||||
current_app.logger.warning(
|
||||
"/api/agent/hash_list denied non-local request from %s", remote_addr
|
||||
)
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
try:
|
||||
records = _inventory().collect_agent_hash_records()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
current_app.logger.exception("/api/agent/hash_list error: %s", exc)
|
||||
return jsonify({"error": "internal error"}), 500
|
||||
return jsonify({"agents": records})
|
||||
|
||||
|
||||
@blueprint.route("/api/device_list_views", methods=["GET"])
|
||||
def list_device_list_views() -> object:
|
||||
views = _views().list_views()
|
||||
return jsonify({"views": [view.to_dict() for view in views]})
|
||||
|
||||
|
||||
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["GET"])
|
||||
def get_device_list_view(view_id: int) -> object:
|
||||
view = _views().get_view(view_id)
|
||||
if not view:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(view.to_dict())
|
||||
|
||||
|
||||
@blueprint.route("/api/device_list_views", methods=["POST"])
|
||||
def create_device_list_view() -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
name = (payload.get("name") or "").strip()
|
||||
columns = payload.get("columns") or []
|
||||
filters = payload.get("filters") or {}
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
if name.lower() == "default view":
|
||||
return jsonify({"error": "reserved name"}), 400
|
||||
if not isinstance(columns, list) or not all(isinstance(x, str) for x in columns):
|
||||
return jsonify({"error": "columns must be a list of strings"}), 400
|
||||
if not isinstance(filters, dict):
|
||||
return jsonify({"error": "filters must be an object"}), 400
|
||||
|
||||
try:
|
||||
view = _views().create_view(name, columns, filters)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "duplicate":
|
||||
return jsonify({"error": "name already exists"}), 409
|
||||
raise
|
||||
response = jsonify(view.to_dict())
|
||||
response.status_code = 201
|
||||
return response
|
||||
|
||||
|
||||
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["PUT"])
|
||||
def update_device_list_view(view_id: int) -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
updates: dict = {}
|
||||
if "name" in payload:
|
||||
name_val = payload.get("name")
|
||||
if name_val is None:
|
||||
return jsonify({"error": "name cannot be empty"}), 400
|
||||
normalized = (str(name_val) or "").strip()
|
||||
if not normalized:
|
||||
return jsonify({"error": "name cannot be empty"}), 400
|
||||
if normalized.lower() == "default view":
|
||||
return jsonify({"error": "reserved name"}), 400
|
||||
updates["name"] = normalized
|
||||
if "columns" in payload:
|
||||
columns_val = payload.get("columns")
|
||||
if not isinstance(columns_val, list) or not all(isinstance(x, str) for x in columns_val):
|
||||
return jsonify({"error": "columns must be a list of strings"}), 400
|
||||
updates["columns"] = columns_val
|
||||
if "filters" in payload:
|
||||
filters_val = payload.get("filters")
|
||||
if filters_val is not None and not isinstance(filters_val, dict):
|
||||
return jsonify({"error": "filters must be an object"}), 400
|
||||
if filters_val is not None:
|
||||
updates["filters"] = filters_val
|
||||
if not updates:
|
||||
return jsonify({"error": "no fields to update"}), 400
|
||||
|
||||
try:
|
||||
view = _views().update_view(
|
||||
view_id,
|
||||
name=updates.get("name"),
|
||||
columns=updates.get("columns"),
|
||||
filters=updates.get("filters"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == "duplicate":
|
||||
return jsonify({"error": "name already exists"}), 409
|
||||
if code == "missing_name":
|
||||
return jsonify({"error": "name cannot be empty"}), 400
|
||||
if code == "reserved":
|
||||
return jsonify({"error": "reserved name"}), 400
|
||||
return jsonify({"error": "invalid payload"}), 400
|
||||
except LookupError:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(view.to_dict())
|
||||
|
||||
|
||||
@blueprint.route("/api/device_list_views/<int:view_id>", methods=["DELETE"])
|
||||
def delete_device_list_view(view_id: int) -> object:
|
||||
if not _views().delete_view(view_id):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
def _remote_devices_endpoint(connection_type: str) -> object:
|
||||
guard = _require_admin()
|
||||
if guard:
|
||||
return guard
|
||||
if request.method == "GET":
|
||||
devices = _inventory().list_remote_devices(connection_type)
|
||||
return jsonify({"devices": devices})
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
hostname = (payload.get("hostname") or "").strip()
|
||||
address = (
|
||||
payload.get("address")
|
||||
or payload.get("connection_endpoint")
|
||||
or payload.get("endpoint")
|
||||
or payload.get("host")
|
||||
)
|
||||
description = payload.get("description")
|
||||
os_hint = payload.get("operating_system") or payload.get("os")
|
||||
|
||||
if not hostname:
|
||||
return jsonify({"error": "hostname is required"}), 400
|
||||
if not (address or "").strip():
|
||||
return jsonify({"error": "address is required"}), 400
|
||||
|
||||
try:
|
||||
device = _inventory().upsert_remote_device(
|
||||
connection_type,
|
||||
hostname,
|
||||
address,
|
||||
description,
|
||||
os_hint,
|
||||
ensure_existing_type=None,
|
||||
)
|
||||
except RemoteDeviceError as exc:
|
||||
status = 409 if exc.code in {"conflict", "address_required"} else 500
|
||||
if exc.code == "conflict":
|
||||
return jsonify({"error": str(exc)}), 409
|
||||
if exc.code == "address_required":
|
||||
return jsonify({"error": "address is required"}), 400
|
||||
return jsonify({"error": str(exc)}), status
|
||||
return jsonify({"device": device}), 201
|
||||
|
||||
|
||||
def _remote_device_detail(connection_type: str, hostname: str) -> object:
|
||||
guard = _require_admin()
|
||||
if guard:
|
||||
return guard
|
||||
normalized_host = (hostname or "").strip()
|
||||
if not normalized_host:
|
||||
return jsonify({"error": "invalid hostname"}), 400
|
||||
|
||||
if request.method == "DELETE":
|
||||
try:
|
||||
_inventory().delete_remote_device(connection_type, normalized_host)
|
||||
except RemoteDeviceError as exc:
|
||||
if exc.code == "not_found":
|
||||
return jsonify({"error": "device not found"}), 404
|
||||
if exc.code == "invalid_hostname":
|
||||
return jsonify({"error": "invalid hostname"}), 400
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
address = (
|
||||
payload.get("address")
|
||||
or payload.get("connection_endpoint")
|
||||
or payload.get("endpoint")
|
||||
)
|
||||
description = payload.get("description")
|
||||
os_hint = payload.get("operating_system") or payload.get("os")
|
||||
|
||||
if address is None and description is None and os_hint is None:
|
||||
return jsonify({"error": "no fields to update"}), 400
|
||||
|
||||
try:
|
||||
device = _inventory().upsert_remote_device(
|
||||
connection_type,
|
||||
normalized_host,
|
||||
address if address is not None else "",
|
||||
description,
|
||||
os_hint,
|
||||
ensure_existing_type=connection_type,
|
||||
)
|
||||
except RemoteDeviceError as exc:
|
||||
if exc.code == "not_found":
|
||||
return jsonify({"error": "device not found"}), 404
|
||||
if exc.code == "address_required":
|
||||
return jsonify({"error": "address is required"}), 400
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
return jsonify({"device": device})
|
||||
|
||||
|
||||
__all__ = ["register", "blueprint"]
|
||||
Reference in New Issue
Block a user