mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-06 10:50:31 -07:00
311 lines
12 KiB
Python
311 lines
12 KiB
Python
# ======================================================
|
|
# Data\Engine\services\API\devices\routes.py
|
|
# Description: Agent heartbeat and script polling endpoints aligned with device management APIs.
|
|
#
|
|
# API Endpoints (if applicable):
|
|
# - POST /api/agent/heartbeat (Device Authenticated) - Updates device last-seen metadata and inventory snapshots.
|
|
# - POST /api/agent/script/request (Device Authenticated) - Provides script execution payloads or idle signals to agents.
|
|
# - POST /api/agent/vpn/ensure (Device Authenticated) - Ensures persistent WireGuard tunnel material.
|
|
# ======================================================
|
|
|
|
"""Device-affiliated agent endpoints for the Borealis Engine runtime."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
from urllib.parse import urlsplit
|
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
|
|
from flask import Blueprint, jsonify, request, g
|
|
|
|
from ....auth.device_auth import AGENT_CONTEXT_HEADER, require_device_auth
|
|
from ....auth.guid_utils import normalize_guid
|
|
from .tunnel import _get_tunnel_service
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - typing aide
|
|
from .. import EngineServiceAdapters
|
|
|
|
|
|
def _canonical_context(value: Optional[str]) -> Optional[str]:
|
|
if not value:
|
|
return None
|
|
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in ("_", "-"))
|
|
if not cleaned:
|
|
return None
|
|
return cleaned.upper()
|
|
|
|
|
|
def _json_or_none(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return json.dumps(value)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _infer_endpoint_host(req) -> str:
|
|
forwarded = (req.headers.get("X-Forwarded-Host") or req.headers.get("X-Original-Host") or "").strip()
|
|
host = forwarded.split(",")[0].strip() if forwarded else (req.host or "").strip()
|
|
if not host:
|
|
return ""
|
|
try:
|
|
parsed = urlsplit(f"//{host}")
|
|
if parsed.hostname:
|
|
return parsed.hostname
|
|
except Exception:
|
|
return host
|
|
return host
|
|
|
|
|
|
def register_agents(app, adapters: "EngineServiceAdapters") -> None:
|
|
"""Register agent heartbeat and script polling routes."""
|
|
|
|
blueprint = Blueprint("agents", __name__)
|
|
auth_manager = adapters.device_auth_manager
|
|
log = adapters.service_log
|
|
db_conn_factory = adapters.db_conn_factory
|
|
script_signer = adapters.script_signer
|
|
|
|
def _context_hint(ctx: Optional[Any] = None) -> Optional[str]:
|
|
if ctx is not None and getattr(ctx, "service_mode", None):
|
|
return _canonical_context(getattr(ctx, "service_mode", None))
|
|
return _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
|
|
|
|
def _auth_context() -> Any:
|
|
ctx = getattr(g, "device_auth", None)
|
|
if ctx is None:
|
|
log("agents", f"device auth context missing for {request.path}", _context_hint())
|
|
return ctx
|
|
|
|
@blueprint.route("/api/agent/heartbeat", methods=["POST"])
|
|
@require_device_auth(auth_manager)
|
|
def heartbeat():
|
|
ctx = _auth_context()
|
|
if ctx is None:
|
|
return jsonify({"error": "auth_context_missing"}), 500
|
|
|
|
payload = request.get_json(force=True, silent=True) or {}
|
|
context_label = _context_hint(ctx)
|
|
now_ts = int(time.time())
|
|
|
|
updates: Dict[str, Optional[str]] = {"last_seen": now_ts}
|
|
|
|
hostname = payload.get("hostname")
|
|
if isinstance(hostname, str) and hostname.strip():
|
|
updates["hostname"] = hostname.strip()
|
|
|
|
inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
|
|
for key in ("memory", "network", "software", "storage", "cpu"):
|
|
if key in inventory and inventory[key] is not None:
|
|
encoded = _json_or_none(inventory[key])
|
|
if encoded is not None:
|
|
updates[key] = encoded
|
|
|
|
metrics = payload.get("metrics") if isinstance(payload.get("metrics"), dict) else {}
|
|
if metrics.get("last_user"):
|
|
updates["last_user"] = str(metrics["last_user"])
|
|
if metrics.get("operating_system"):
|
|
updates["operating_system"] = str(metrics["operating_system"])
|
|
if metrics.get("uptime") is not None:
|
|
try:
|
|
updates["uptime"] = int(metrics["uptime"])
|
|
except Exception:
|
|
pass
|
|
|
|
for field in ("external_ip", "internal_ip", "device_type"):
|
|
if payload.get(field):
|
|
updates[field] = str(payload[field])
|
|
|
|
conn = db_conn_factory()
|
|
try:
|
|
cur = conn.cursor()
|
|
|
|
def _apply_updates() -> int:
|
|
if not updates:
|
|
return 0
|
|
columns = ", ".join(f"{col} = ?" for col in updates.keys())
|
|
values = list(updates.values())
|
|
normalized_guid = normalize_guid(ctx.guid)
|
|
selected_guid: Optional[str] = None
|
|
if normalized_guid:
|
|
cur.execute(
|
|
"SELECT guid FROM devices WHERE UPPER(guid) = ?",
|
|
(normalized_guid,),
|
|
)
|
|
rows = cur.fetchall()
|
|
for (stored_guid,) in rows or []:
|
|
if stored_guid == ctx.guid:
|
|
selected_guid = stored_guid
|
|
break
|
|
if not selected_guid and rows:
|
|
selected_guid = rows[0][0]
|
|
target_guid = selected_guid or ctx.guid
|
|
cur.execute(
|
|
f"UPDATE devices SET {columns} WHERE guid = ?",
|
|
values + [target_guid],
|
|
)
|
|
updated = cur.rowcount
|
|
if updated > 0 and normalized_guid and target_guid != normalized_guid:
|
|
try:
|
|
cur.execute(
|
|
"UPDATE devices SET guid = ? WHERE guid = ?",
|
|
(normalized_guid, target_guid),
|
|
)
|
|
except sqlite3.IntegrityError:
|
|
pass
|
|
return updated
|
|
|
|
try:
|
|
rowcount = _apply_updates()
|
|
except sqlite3.IntegrityError as exc:
|
|
if "devices.hostname" in str(exc) and "UNIQUE" in str(exc).upper():
|
|
existing_guid_for_hostname: Optional[str] = None
|
|
if "hostname" in updates:
|
|
try:
|
|
cur.execute(
|
|
"SELECT guid FROM devices WHERE hostname = ?",
|
|
(updates["hostname"],),
|
|
)
|
|
row = cur.fetchone()
|
|
if row and row[0]:
|
|
existing_guid_for_hostname = normalize_guid(row[0])
|
|
except Exception:
|
|
existing_guid_for_hostname = None
|
|
updates.pop("hostname", None)
|
|
rowcount = _apply_updates()
|
|
try:
|
|
current_guid = normalize_guid(ctx.guid)
|
|
except Exception:
|
|
current_guid = ctx.guid
|
|
if (
|
|
existing_guid_for_hostname
|
|
and current_guid
|
|
and existing_guid_for_hostname != current_guid
|
|
):
|
|
log(
|
|
"agents",
|
|
f"heartbeat hostname collision ignored for guid={ctx.guid}",
|
|
context_label,
|
|
level="WARNING",
|
|
)
|
|
else:
|
|
raise
|
|
|
|
if rowcount == 0:
|
|
log("agents", f"heartbeat missing device record guid={ctx.guid}", context_label, level="ERROR")
|
|
return jsonify({"error": "device_not_registered"}), 404
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
return jsonify({"status": "ok", "poll_after_ms": 15000})
|
|
|
|
@blueprint.route("/api/agent/script/request", methods=["POST"])
|
|
@require_device_auth(auth_manager)
|
|
def script_request():
|
|
ctx = _auth_context()
|
|
if ctx is None:
|
|
return jsonify({"error": "auth_context_missing"}), 500
|
|
|
|
signing_key = ""
|
|
if script_signer is not None:
|
|
try:
|
|
signing_key = script_signer.public_base64_spki()
|
|
except Exception:
|
|
signing_key = ""
|
|
|
|
if ctx.status != "active":
|
|
return jsonify(
|
|
{
|
|
"status": "quarantined",
|
|
"poll_after_ms": 60000,
|
|
"sig_alg": "ed25519",
|
|
"signing_key": signing_key,
|
|
}
|
|
)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "idle",
|
|
"poll_after_ms": 30000,
|
|
"sig_alg": "ed25519",
|
|
"signing_key": signing_key,
|
|
}
|
|
)
|
|
|
|
@blueprint.route("/api/agent/vpn/ensure", methods=["POST"])
|
|
@require_device_auth(auth_manager)
|
|
def vpn_ensure():
|
|
ctx = _auth_context()
|
|
if ctx is None:
|
|
return jsonify({"error": "auth_context_missing"}), 500
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
requested_agent = (body.get("agent_id") or "").strip()
|
|
guid = normalize_guid(ctx.guid)
|
|
|
|
conn = db_conn_factory()
|
|
resolved_agent = ""
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT agent_id FROM devices WHERE UPPER(guid) = ? ORDER BY last_seen DESC LIMIT 1",
|
|
(guid,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row and row[0]:
|
|
resolved_agent = str(row[0]).strip()
|
|
finally:
|
|
conn.close()
|
|
|
|
if not resolved_agent:
|
|
log("VPN_Tunnel/tunnel", f"vpn_agent_ensure_missing_agent guid={guid}", _context_hint(ctx), level="ERROR")
|
|
return jsonify({"error": "agent_id_missing"}), 404
|
|
|
|
if requested_agent and requested_agent != resolved_agent:
|
|
log(
|
|
"VPN_Tunnel/tunnel",
|
|
"vpn_agent_ensure_agent_mismatch requested={0} resolved={1}".format(
|
|
requested_agent, resolved_agent
|
|
),
|
|
_context_hint(ctx),
|
|
level="WARNING",
|
|
)
|
|
|
|
try:
|
|
tunnel_service = _get_tunnel_service(adapters)
|
|
endpoint_host = _infer_endpoint_host(request)
|
|
log(
|
|
"VPN_Tunnel/tunnel",
|
|
"vpn_agent_ensure_request agent_id={0} endpoint_host={1}".format(
|
|
resolved_agent, endpoint_host or "-"
|
|
),
|
|
_context_hint(ctx),
|
|
)
|
|
payload = tunnel_service.connect(
|
|
agent_id=resolved_agent,
|
|
operator_id=None,
|
|
endpoint_host=endpoint_host,
|
|
)
|
|
except Exception as exc:
|
|
log(
|
|
"VPN_Tunnel/tunnel",
|
|
"vpn_agent_ensure_failed agent_id={0} error={1}".format(resolved_agent, str(exc)),
|
|
_context_hint(ctx),
|
|
level="ERROR",
|
|
)
|
|
return jsonify({"error": "tunnel_start_failed", "detail": str(exc)}), 500
|
|
|
|
log(
|
|
"VPN_Tunnel/tunnel",
|
|
"vpn_agent_ensure_response agent_id={0} tunnel_id={1}".format(
|
|
payload.get("agent_id", resolved_agent), payload.get("tunnel_id", "-")
|
|
),
|
|
_context_hint(ctx),
|
|
)
|
|
return jsonify(payload), 200
|
|
|
|
app.register_blueprint(blueprint)
|