feat: add safe miner control foundation

This commit is contained in:
lussierj committed 2026-08-24 20:49:28 +00:00
1 parent 6cd876b4b3
commit f5965af914
39 files changed
+2952 -491

No files matched your search

+20 -165
View File
@@ -11,6 +11,8 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.agent_commands.router import router as agent_commands_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 (
COOKIE_TOKEN_HINT,
@@ -63,6 +65,8 @@ app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(frontend_router)
app.include_router(inventory_router)
app.include_router(miner_control_router)
app.include_router(agent_commands_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
@@ -4010,6 +4014,8 @@ def minerops_process_due_scheduled_bulk_actions():
conn.close()
app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions
def minerops_reconcile_scheduled_bulk_action_statuses():
minerops_ensure_scheduled_bulk_actions_table()
@@ -24985,8 +24991,7 @@ async def minerops_hub_mode_js():
# -----------------------------
def minerops_agent_secret_expected() -> str:
import os
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
return settings.minerops_agent_shared_secret.strip()
def minerops_require_agent_secret(request: Request):
@@ -25089,6 +25094,7 @@ async def minerops_agent_checkin(request: Request):
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_id = str(body.get("site_id") or "").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
hostname = str(body.get("hostname") or "").strip()
version = str(body.get("version") or "").strip()
@@ -25101,6 +25107,7 @@ async def minerops_agent_checkin(request: Request):
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
site_name,
hostname,
version,
@@ -25108,9 +25115,10 @@ async def minerops_agent_checkin(request: Request):
last_checkin_at,
last_payload
)
VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb)
VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb)
ON CONFLICT (agent_id)
DO UPDATE SET
site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id),
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
@@ -25119,6 +25127,7 @@ async def minerops_agent_checkin(request: Request):
last_payload = EXCLUDED.last_payload
""",
agent_id,
site_id,
site_name,
hostname,
version,
@@ -25301,12 +25310,11 @@ async def minerops_agent_setup_info(current_user: dict = Depends(get_current_use
return {
"ok": True,
"hub_url": "https://plexus.lussier.cc",
"agent_id": "plexus-windows-agent",
"agent_id": settings.minerops_agent_id,
"site_name": "Plexus",
"agent_secret": secret,
"secret_length": len(secret),
"secret_configured": bool(secret),
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
"message": "Agent credentials are provisioned locally and are never returned to browsers.",
}
@@ -25353,7 +25361,7 @@ async def minerops_agent_setup_page():
<div class="card">
<h2>Agent Connection Info</h2>
<div class="warn">
Keep this secret private. Anyone with the secret can submit agent data to this hub.
Agent credentials must be provisioned directly on the Windows host.
</div>
<label>Hub URL</label>
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page():
<label>Site Name</label>
<input id="siteName" readonly>
<label>Agent Shared Secret</label>
<input id="agentSecret" type="password" readonly>
<p>
<button onclick="toggleSecret()">Show / Hide Secret</button>
<button onclick="copySecret()">Copy Secret</button>
<button onclick="copyConfig()">Copy Full Config JSON</button>
</p>
</div>
<div class="card">
<h2>Windows Agent Config JSON</h2>
<p class="small">Paste this into <strong>C:\\MinerOpsAgent\\agent-config.json</strong> if you need to recreate the agent config.</p>
<textarea id="configJson" readonly></textarea>
<p id="secretStatus" class="small"></p>
</div>
<div class="card">
@@ -25427,42 +25422,14 @@ async function loadSetup() {
document.getElementById("hubUrl").value = data.hub_url || "";
document.getElementById("agentId").value = data.agent_id || "";
document.getElementById("siteName").value = data.site_name || "";
document.getElementById("agentSecret").value = data.agent_secret || "";
const config = {
agent_id: data.agent_id,
site_name: data.site_name,
hub_url: data.hub_url,
agent_secret: data.agent_secret,
poll_interval_seconds: 120,
ip_ranges: [
{ name: "Unit 4", cidr: "10.30.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
{ name: "Unit 6", cidr: "10.80.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
{ name: "Unit 12", cidr: "10.90.0.0/22", enabled: false, api_port: 4028, api3_port: 4433 }
]
};
document.getElementById("configJson").value = JSON.stringify(config, null, 2);
document.getElementById("secretStatus").textContent = data.secret_configured
? "A shared Agent secret is configured on the Hub. Its value is not exposed here."
: "The Hub does not currently have an Agent secret configured.";
status.className = "ok";
status.textContent = "Agent setup info loaded.";
}
function toggleSecret() {
const input = document.getElementById("agentSecret");
input.type = input.type === "password" ? "text" : "password";
}
async function copySecret() {
await navigator.clipboard.writeText(document.getElementById("agentSecret").value);
alert("Secret copied.");
}
async function copyConfig() {
await navigator.clipboard.writeText(document.getElementById("configJson").value);
alert("Config JSON copied.");
}
document.addEventListener("DOMContentLoaded", loadSetup);
</script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
@@ -25939,118 +25906,6 @@ async def minerops_agent_command_status(current_user: dict = Depends(get_current
}
@app.get("/api/agent/commands/pending")
async def minerops_agent_commands_pending_v2(request: Request):
# __SCHEDULE_DUE_HOOK_V2__
try:
minerops_process_due_scheduled_bulk_actions()
except NameError:
pass
except Exception as exc:
print(f"Scheduled bulk action due processing error: {exc}")
minerops_require_agent_secret(request)
await minerops_ensure_agent_command_queue_table()
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'claimed',
claimed_at = now()
WHERE id IN (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = $1
AND command_status = 'pending'
ORDER BY created_at ASC
LIMIT 5
)
RETURNING
id,
command_type,
request_payload,
created_at
""",
agent_id,
)
finally:
await conn.close()
commands = []
for row in rows:
commands.append(
{
"command_id": row["id"],
"command_type": row["command_type"],
"request_payload": row["request_payload"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
)
return {
"ok": True,
"commands": commands,
}
@app.post("/api/agent/commands/result")
async def minerops_agent_commands_result_v2(request: Request):
import json
minerops_require_agent_secret(request)
body = await request.json()
command_id = str(body.get("command_id") or "").strip()
status = str(body.get("status") or "").strip().lower()
result_payload = body.get("result_payload") or {}
error_message = body.get("error_message")
if not command_id:
raise HTTPException(status_code=400, detail="Missing command_id.")
if status not in {"completed", "failed"}:
status = "completed"
await minerops_ensure_agent_command_queue_table()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = $2,
result_payload = $3::jsonb,
error_message = $4,
completed_at = now()
WHERE id = $1
""",
command_id,
status,
json.dumps(result_payload),
error_message,
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"status": status,
"message": "Agent command result received.",
}
# -----------------------------
# Phase 5K.5: Agent control web page