feat: add secure agent enrollment

This commit is contained in:
lussierj committed 2026-08-26 21:31:09 +00:00
1 parent 82309db79a
commit d9f0da694b
26 files changed
+2942 -76

No files matched your search

+154 -55
View File
@@ -17,6 +17,7 @@ from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.models import MinerZoneAssociationResult
from app.agent_zones.service import AgentZoneService
from app.agent_zones.router import router as agent_zones_router
from app.agent_enrollment.router import router as agent_enrollment_router
from app.miner_control.router import router as miner_control_router
from app.dashboard.router import router as dashboard_router
from app.identity import (
@@ -73,6 +74,7 @@ app.include_router(inventory_router)
app.include_router(miner_control_router)
app.include_router(agent_commands_router)
app.include_router(agent_zones_router)
app.include_router(agent_enrollment_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
@@ -4167,24 +4169,27 @@ def minerops_ensure_pool_info_table():
@app.post("/api/agent/pool-snapshots")
async def agent_pool_snapshots(request: Request):
async def agent_pool_snapshots(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json as _json
import os as _os
import uuid as _uuid
expected_secret = (_os.getenv("MINEROPS_AGENT_SHARED_SECRET") or "").strip()
provided_secret = (
request.headers.get("X-MinerOps-Agent-Secret")
or request.headers.get("x-minerops-agent-secret")
or ""
).strip()
if expected_secret and provided_secret != expected_secret:
raise HTTPException(status_code=401, detail="Invalid agent secret.")
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
body = await request.json()
agent_id = body.get("agent_id") or ""
agent_id = authenticated_agent_id
snapshots = body.get("pool_snapshots") or body.get("snapshots") or []
minerops_ensure_pool_info_table()
@@ -4236,28 +4241,34 @@ async def agent_pool_snapshots(request: Request):
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper(%s)
OR host(current_ip) = %s
WHERE agent_zone_id = %s
AND (
upper(current_mac) = upper(%s)
OR host(current_ip) = %s
)
LIMIT 1;
""",
(mac, ip),
(routing.agent_zone_id, mac, ip),
)
else:
cur.execute(
"""
SELECT id
FROM miners
WHERE host(current_ip) = %s
WHERE agent_zone_id = %s
AND host(current_ip) = %s
LIMIT 1;
""",
(ip,),
(routing.agent_zone_id, ip),
)
row = cur.fetchone()
if row:
miner_id = row["id"]
matched += 1
if not row:
continue
miner_id = row["id"]
matched += 1
for pool in pools:
if not isinstance(pool, dict):
@@ -25402,8 +25413,19 @@ document.addEventListener("DOMContentLoaded", loadSetup);
# -----------------------------
@app.get("/api/agent/known-miners")
async def minerops_agent_known_miners(request: Request):
minerops_require_agent_secret(request)
async def minerops_agent_known_miners(
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
conn = await minerops_command_db_connect()
@@ -25420,8 +25442,10 @@ async def minerops_agent_known_miners(request: Request):
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
AND miners.agent_zone_id = $1::uuid
ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip
"""
""",
str(routing.agent_zone_id),
)
finally:
await conn.close()
@@ -26964,10 +26988,22 @@ async def minerops_agent_discovery_review(
@app.post("/api/agent/miner-enrichment")
async def minerops_agent_miner_enrichment(request: Request):
async def minerops_agent_miner_enrichment(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
await minerops_require_agent_secret(request)
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json()
@@ -27013,16 +27049,18 @@ async def minerops_agent_miner_enrichment(request: Request):
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1
""", miner_id)
""", miner_id, str(routing.agent_zone_id))
if miner is None and ip_address:
miner = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1
""", ip_address)
""", ip_address, str(routing.agent_zone_id))
if miner is None:
raise HTTPException(status_code=404, detail="Miner not found.")
@@ -27110,10 +27148,22 @@ async def minerops_agent_miner_enrichment(request: Request):
@app.post("/api/agent/miner-enrichment-v2")
async def minerops_agent_miner_enrichment_v2(request: Request):
async def minerops_agent_miner_enrichment_v2(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
await minerops_require_agent_secret(request)
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json()
@@ -27175,16 +27225,18 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1
""", miner_id)
""", miner_id, str(routing.agent_zone_id))
if source is None and ip_address:
source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1
""", ip_address)
""", ip_address, str(routing.agent_zone_id))
if source is None:
raise HTTPException(status_code=404, detail="Source miner not found.")
@@ -27199,8 +27251,9 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE upper(current_mac) = upper($1)
AND agent_zone_id = $2::uuid
LIMIT 1
""", mac)
""", mac, str(routing.agent_zone_id))
if existing_mac_miner and existing_mac_miner["id"] != source_id:
target_id = existing_mac_miner["id"]
@@ -27285,7 +27338,8 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, target_id)
AND agent_zone_id = $3::uuid
""", ip_address, target_id, str(routing.agent_zone_id))
except Exception:
pass
@@ -27324,10 +27378,22 @@ async def minerops_agent_miner_enrichment_v2(request: Request):
@app.post("/api/agent/miner-enrichment-v3")
async def minerops_agent_miner_enrichment_v3(request: Request):
async def minerops_agent_miner_enrichment_v3(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
await minerops_require_agent_secret(request)
routing = zone_service.routing_context(authenticated_agent_id)
if routing.routing_reason or routing.agent_zone_id is None:
raise HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
payload = await request.json()
@@ -27384,8 +27450,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id
FROM miners
WHERE upper(current_mac) = upper($1)
AND agent_zone_id = $2::uuid
LIMIT 1
""", mac)
""", mac, str(routing.agent_zone_id))
if row:
target_id = row["id"]
@@ -27396,8 +27463,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
AND agent_zone_id = $2::uuid
LIMIT 1
""", miner_id)
""", miner_id, str(routing.agent_zone_id))
if row:
target_id = row["id"]
@@ -27408,8 +27476,9 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
AND agent_zone_id = $2::uuid
LIMIT 1
""", ip_address)
""", ip_address, str(routing.agent_zone_id))
if row:
target_id = row["id"]
@@ -27473,6 +27542,7 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
AND agent_zone_id = $8::uuid
""",
ip_address,
mac,
@@ -27480,7 +27550,8 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
hostname,
miner_sn,
target_id,
json.dumps({"enrichment": payload, "matched_by": matched_by})
json.dumps({"enrichment": payload, "matched_by": matched_by}),
str(routing.agent_zone_id)
)
updated = await conn.fetchrow("""
@@ -27521,13 +27592,15 @@ async def minerops_agent_miner_enrichment_v3(request: Request):
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
AND agent_zone_id = $7::uuid
""",
ip_address,
mac,
model,
hostname,
miner_sn,
json.dumps({"enrichment": payload, "reason": "no_inventory_match"})
json.dumps({"enrichment": payload, "reason": "no_inventory_match"}),
str(routing.agent_zone_id)
)
return {
@@ -29520,8 +29593,11 @@ async def minerops_inject_clean_detail_v3(request, call_next):
from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException
@app.post("/api/agent/api3-enrichment")
async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
import os
async def minerops_agent_api3_enrichment(
request: MinerOpsApi3Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
import uuid
import psycopg
@@ -29559,19 +29635,23 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
return value
return [value]
expected_secret = os.getenv("MINEROPS_AGENT_SHARED_SECRET", "")
supplied_secret = (
request.headers.get("x-minerops-agent-secret")
or request.headers.get("x-agent-secret")
or ""
)
if expected_secret and supplied_secret != expected_secret:
raise MinerOpsApi3HTTPException(status_code=401, detail="Invalid agent secret.")
routing = zone_service.routing_context(authenticated_agent_id)
if (
routing.routing_reason
or routing.agent_zone_id is None
or routing.site_id is None
):
raise MinerOpsApi3HTTPException(
status_code=409,
detail=(
"Agent routing unavailable: "
+ (routing.routing_reason or "agent_zone_unassigned")
),
)
body = await request.json()
agent_id = as_text(body.get("agent_id")) or "unknown-agent"
site_name = as_text(body.get("site_name")) or "unknown-site"
agent_id = authenticated_agent_id
site_name = None
items = body.get("items") or []
if not isinstance(items, list):
@@ -29592,6 +29672,20 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"SELECT name FROM sites WHERE id = %s",
(routing.site_id,),
)
site_row = cur.fetchone()
if not site_row:
raise MinerOpsApi3HTTPException(
status_code=409,
detail="Agent Site assignment no longer exists.",
)
site_name = str(site_row["name"])
cur.execute(
"""
ALTER TABLE miners
@@ -29772,10 +29866,11 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
SELECT id
FROM miners
WHERE upper(current_mac) = upper(%s)
AND agent_zone_id = %s
ORDER BY updated_at DESC NULLS LAST
LIMIT 1;
""",
(mac,),
(mac, routing.agent_zone_id),
)
row = cur.fetchone()
if row:
@@ -29787,15 +29882,19 @@ async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
SELECT id
FROM miners
WHERE current_ip = %s::inet
AND agent_zone_id = %s
ORDER BY updated_at DESC NULLS LAST
LIMIT 1;
""",
(ip,),
(ip, routing.agent_zone_id),
)
row = cur.fetchone()
if row:
miner_id = row["id"]
if miner_id is None:
continue
if miner_id is not None:
matched += 1