mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 03:21:57 -06:00
Port core API routes for sites and devices
This commit is contained in:
@@ -6,7 +6,20 @@ from flask import Flask
|
||||
|
||||
from Data.Engine.services.container import EngineServiceContainer
|
||||
|
||||
from . import admin, agents, auth, enrollment, github, health, job_management, tokens, users
|
||||
from . import (
|
||||
admin,
|
||||
agents,
|
||||
auth,
|
||||
enrollment,
|
||||
github,
|
||||
health,
|
||||
job_management,
|
||||
tokens,
|
||||
users,
|
||||
sites,
|
||||
devices,
|
||||
credentials,
|
||||
)
|
||||
|
||||
_REGISTRARS = (
|
||||
health.register,
|
||||
@@ -18,6 +31,9 @@ _REGISTRARS = (
|
||||
auth.register,
|
||||
admin.register,
|
||||
users.register,
|
||||
sites.register,
|
||||
devices.register,
|
||||
credentials.register,
|
||||
)
|
||||
|
||||
|
||||
|
||||
70
Data/Engine/interfaces/http/credentials.py
Normal file
70
Data/Engine/interfaces/http/credentials.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Flask, current_app, jsonify, request, session
|
||||
|
||||
from Data.Engine.services.container import EngineServiceContainer
|
||||
|
||||
blueprint = Blueprint("engine_credentials", __name__)
|
||||
|
||||
|
||||
def register(app: Flask, _services: EngineServiceContainer) -> None:
|
||||
if "engine_credentials" 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 _credentials_service():
|
||||
return _services().credential_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
|
||||
|
||||
|
||||
@blueprint.route("/api/credentials", methods=["GET"])
|
||||
def list_credentials() -> object:
|
||||
guard = _require_admin()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
site_id_param = request.args.get("site_id")
|
||||
connection_type = (request.args.get("connection_type") or "").strip() or None
|
||||
try:
|
||||
site_id = int(site_id_param) if site_id_param not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
site_id = None
|
||||
|
||||
records = _credentials_service().list_credentials(
|
||||
site_id=site_id,
|
||||
connection_type=connection_type,
|
||||
)
|
||||
return jsonify({"credentials": records})
|
||||
|
||||
|
||||
@blueprint.route("/api/credentials", methods=["POST"])
|
||||
def create_credential() -> object: # pragma: no cover - placeholder
|
||||
return jsonify({"error": "not implemented"}), 501
|
||||
|
||||
|
||||
@blueprint.route("/api/credentials/<int:credential_id>", methods=["GET", "PUT", "DELETE"])
|
||||
def credential_detail(credential_id: int) -> object: # pragma: no cover - placeholder
|
||||
if request.method == "GET":
|
||||
return jsonify({"error": "not implemented"}), 501
|
||||
if request.method == "DELETE":
|
||||
return jsonify({"error": "not implemented"}), 501
|
||||
return jsonify({"error": "not implemented"}), 501
|
||||
|
||||
|
||||
__all__ = ["register", "blueprint"]
|
||||
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"]
|
||||
112
Data/Engine/interfaces/http/sites.py
Normal file
112
Data/Engine/interfaces/http/sites.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Flask, current_app, jsonify, request
|
||||
|
||||
from Data.Engine.services.container import EngineServiceContainer
|
||||
|
||||
blueprint = Blueprint("engine_sites", __name__)
|
||||
|
||||
|
||||
def register(app: Flask, _services: EngineServiceContainer) -> None:
|
||||
if "engine_sites" 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 _site_service():
|
||||
return _services().site_service
|
||||
|
||||
|
||||
@blueprint.route("/api/sites", methods=["GET"])
|
||||
def list_sites() -> object:
|
||||
records = _site_service().list_sites()
|
||||
return jsonify({"sites": [record.to_dict() for record in records]})
|
||||
|
||||
|
||||
@blueprint.route("/api/sites", methods=["POST"])
|
||||
def create_site() -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
name = payload.get("name")
|
||||
description = payload.get("description")
|
||||
try:
|
||||
record = _site_service().create_site(name or "", description or "")
|
||||
except ValueError as exc:
|
||||
if str(exc) == "missing_name":
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
if str(exc) == "duplicate":
|
||||
return jsonify({"error": "name already exists"}), 409
|
||||
raise
|
||||
response = jsonify(record.to_dict())
|
||||
response.status_code = 201
|
||||
return response
|
||||
|
||||
|
||||
@blueprint.route("/api/sites/delete", methods=["POST"])
|
||||
def delete_sites() -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
ids = payload.get("ids") or []
|
||||
if not isinstance(ids, list):
|
||||
return jsonify({"error": "ids must be a list"}), 400
|
||||
deleted = _site_service().delete_sites(ids)
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
|
||||
|
||||
@blueprint.route("/api/sites/device_map", methods=["GET"])
|
||||
def sites_device_map() -> object:
|
||||
host_param = (request.args.get("hostnames") or "").strip()
|
||||
filter_set = []
|
||||
if host_param:
|
||||
for part in host_param.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
filter_set.append(normalized)
|
||||
mapping = _site_service().map_devices(filter_set or None)
|
||||
return jsonify({"mapping": {hostname: entry.to_dict() for hostname, entry in mapping.items()}})
|
||||
|
||||
|
||||
@blueprint.route("/api/sites/assign", methods=["POST"])
|
||||
def assign_devices_to_site() -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
site_id = payload.get("site_id")
|
||||
hostnames = payload.get("hostnames") or []
|
||||
if not isinstance(hostnames, list):
|
||||
return jsonify({"error": "hostnames must be a list of strings"}), 400
|
||||
try:
|
||||
_site_service().assign_devices(site_id, hostnames)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
if message == "invalid_site_id":
|
||||
return jsonify({"error": "invalid site_id"}), 400
|
||||
if message == "invalid_hostnames":
|
||||
return jsonify({"error": "hostnames must be a list of strings"}), 400
|
||||
raise
|
||||
except LookupError:
|
||||
return jsonify({"error": "site not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@blueprint.route("/api/sites/rename", methods=["POST"])
|
||||
def rename_site() -> object:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
site_id = payload.get("id")
|
||||
new_name = payload.get("new_name") or ""
|
||||
try:
|
||||
record = _site_service().rename_site(site_id, new_name)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "missing_name":
|
||||
return jsonify({"error": "new_name is required"}), 400
|
||||
if str(exc) == "duplicate":
|
||||
return jsonify({"error": "name already exists"}), 409
|
||||
raise
|
||||
except LookupError:
|
||||
return jsonify({"error": "site not found"}), 404
|
||||
return jsonify(record.to_dict())
|
||||
|
||||
|
||||
__all__ = ["register", "blueprint"]
|
||||
Reference in New Issue
Block a user