Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.
Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.
Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
1261 lines
48 KiB
Python
1261 lines
48 KiB
Python
"""Transactional discovery authority and append-only evidence, using existing schema.
|
|
|
|
All writers acquire bounded table locks in the same order. The miners lock also
|
|
serializes INSERT phantoms with legacy writers which do not take our advisory
|
|
lock. This deliberately favors correctness over fleet-scale write concurrency.
|
|
No miner I/O occurs here. No discovered address authorizes a physical command.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone, timedelta
|
|
from hashlib import sha256
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
import math
|
|
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
|
|
|
|
import jwt
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
from psycopg.types.json import Jsonb
|
|
from fastapi import HTTPException
|
|
|
|
from app.config import settings
|
|
from app.agent_zones.repository import default_connection_factory
|
|
|
|
TTL = timedelta(minutes=15)
|
|
MAX_BATCH = 128
|
|
|
|
|
|
def reject(reason, code=409):
|
|
raise HTTPException(status_code=code, detail=reason)
|
|
|
|
|
|
def object_body(body):
|
|
if not isinstance(body, dict):
|
|
reject("discovery_object_required", 422)
|
|
return body
|
|
|
|
|
|
def address(value):
|
|
try:
|
|
text = str(value)
|
|
if "%" in text or "/" in text:
|
|
raise ValueError()
|
|
return str(ipaddress.ip_address(text))
|
|
except (ValueError, TypeError):
|
|
reject("discovery_invalid_ip", 422)
|
|
|
|
|
|
def mac(value):
|
|
if value in (None, ""):
|
|
return None
|
|
if not isinstance(value, str) or not re.fullmatch(
|
|
r"(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|(?:[0-9a-fA-F]{2}-){5}[0-9a-fA-F]{2})",
|
|
value,
|
|
):
|
|
reject("discovery_invalid_mac", 422)
|
|
key = value.replace(":", "").replace("-", "").lower()
|
|
if key in {"000000000000", "ffffffffffff"} or int(key[:2], 16) & 1:
|
|
reject("discovery_invalid_mac", 422)
|
|
return ":".join(key[i : i + 2] for i in range(0, 12, 2)).upper()
|
|
|
|
|
|
def digest(value):
|
|
return sha256(
|
|
json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
|
|
|
|
def text(value, limit=255):
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str) or len(value) > limit:
|
|
reject("discovery_invalid_text", 422)
|
|
return value.strip() or None
|
|
|
|
|
|
def number(value, integer=False):
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
if isinstance(value, bool):
|
|
raise ValueError()
|
|
parsed = float(value)
|
|
if not math.isfinite(parsed):
|
|
raise ValueError()
|
|
return int(parsed) if integer else parsed
|
|
except (ValueError, TypeError, OverflowError):
|
|
reject("discovery_invalid_measurement", 422)
|
|
|
|
|
|
def insert(cur, table, values):
|
|
# Identifiers originate exclusively from fixed mappings in this module.
|
|
cur.execute(
|
|
"INSERT INTO "
|
|
+ table
|
|
+ "("
|
|
+ ",".join(values)
|
|
+ ") VALUES ("
|
|
+ ",".join(["%s"] * len(values))
|
|
+ ")",
|
|
tuple(values.values()),
|
|
)
|
|
|
|
|
|
def status_snapshot(cur, row, raw, api3=False):
|
|
raw = object_body(raw or {})
|
|
if api3:
|
|
msg = object_body(object_body(raw.get("msg") or {}).get("summary") or {})
|
|
names = {
|
|
"hash-average": "mhs_av",
|
|
"power-realtime": "power_w",
|
|
"power-rate": "power_rate_w_per_th",
|
|
"chip-temp-avg": "chip_temp_avg_c",
|
|
"chip-temp-max": "chip_temp_max_c",
|
|
"environment-temperature": "env_temp_c",
|
|
}
|
|
else:
|
|
msg = object_body(raw.get("Msg") or raw.get("msg") or {})
|
|
names = {
|
|
"MHS av": "mhs_av",
|
|
"MHS 1m": "mhs_1m",
|
|
"MHS 15m": "mhs_15m",
|
|
"HS RT": "hs_rt",
|
|
"Power": "power_w",
|
|
"Power Rate": "power_rate_w_per_th",
|
|
"Env Temp": "env_temp_c",
|
|
"Chip Temp Min": "chip_temp_min_c",
|
|
"Chip Temp Max": "chip_temp_max_c",
|
|
"Chip Temp Avg": "chip_temp_avg_c",
|
|
}
|
|
values = {
|
|
"miner_id": row["id"],
|
|
"observed_ip": row["current_ip"],
|
|
"raw_summary_json": Jsonb(raw),
|
|
}
|
|
for key, column in names.items():
|
|
if key in msg:
|
|
values[column] = number(msg[key])
|
|
if api3 and values.get("mhs_av") is not None:
|
|
values["mhs_av"] *= 1000000
|
|
for key, column in (
|
|
{
|
|
"fan-speed-in": "fan_speed_in_rpm",
|
|
"fan-speed-out": "fan_speed_out_rpm",
|
|
"bootup-time": "uptime_seconds",
|
|
}
|
|
if api3
|
|
else {
|
|
"Fan Speed In": "fan_speed_in_rpm",
|
|
"Fan Speed Out": "fan_speed_out_rpm",
|
|
"Uptime": "uptime_seconds",
|
|
}
|
|
).items():
|
|
if key in msg:
|
|
values[column] = number(msg[key], True)
|
|
insert(cur, "miner_status_snapshots", values)
|
|
|
|
|
|
def pool_snapshot(cur, row, pool, agent_id):
|
|
object_body(pool)
|
|
values = {
|
|
"miner_id": row["id"],
|
|
"ip_address": row["current_ip"],
|
|
"mac_address": row["current_mac"],
|
|
"raw_pool_json": Jsonb(pool),
|
|
"agent_id": agent_id,
|
|
}
|
|
for keys, column in [
|
|
(("URL", "url"), "url"),
|
|
(("User", "account", "user"), "worker_user"),
|
|
(("Status", "status"), "pool_status"),
|
|
]:
|
|
v = next((pool[k] for k in keys if k in pool), None)
|
|
if v is not None:
|
|
values[column] = text(v, 2048)
|
|
for keys, column in [
|
|
(("POOL", "id"), "pool_index"),
|
|
(("Accepted", "accepted"), "accepted"),
|
|
(("Rejected", "rejected"), "rejected"),
|
|
]:
|
|
v = next((pool[k] for k in keys if k in pool), None)
|
|
if v is not None:
|
|
values[column] = number(v, True)
|
|
insert(cur, "miner_pool_snapshots", values)
|
|
|
|
|
|
class DiscoveryRepository:
|
|
def __init__(self, connection_factory=default_connection_factory):
|
|
self.connection_factory = connection_factory
|
|
|
|
@contextmanager
|
|
def transaction(self):
|
|
with self.connection_factory() as conn:
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
self.lock(cur)
|
|
yield cur
|
|
|
|
@staticmethod
|
|
def lock(cur):
|
|
cur.execute("SET LOCAL lock_timeout = '5s'")
|
|
cur.execute("SET LOCAL statement_timeout = '15s'")
|
|
cur.execute("SELECT pg_advisory_xact_lock(743190031)")
|
|
cur.execute(
|
|
"LOCK TABLE minerops_site_agents, agent_zones, agent_zone_network_ranges IN SHARE MODE"
|
|
)
|
|
cur.execute("LOCK TABLE miners IN SHARE ROW EXCLUSIVE MODE")
|
|
|
|
@staticmethod
|
|
def audit(cur, action, entity, value, actor=None, old=None):
|
|
cur.execute(
|
|
"""INSERT INTO audit_logs
|
|
(actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, %s, 'discovery', %s, %s, %s)""",
|
|
(
|
|
actor,
|
|
action,
|
|
entity,
|
|
Jsonb(old) if old is not None else None,
|
|
Jsonb(value),
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def context(cur, agent_id):
|
|
cur.execute(
|
|
"""SELECT a.agent_id, a.site_id, a.agent_zone_id,
|
|
a.credential_created_at, a.credential_revoked_at,
|
|
z.updated_at AS zone_version, z.is_enabled, s.name AS site_name
|
|
FROM minerops_site_agents a JOIN agent_zones z ON z.id=a.agent_zone_id
|
|
AND z.site_id=a.site_id JOIN sites s ON s.id=a.site_id
|
|
WHERE a.agent_id=%s FOR SHARE OF a,z""",
|
|
(agent_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row or not row["is_enabled"] or row["credential_revoked_at"] is not None:
|
|
reject("discovery_routing_unavailable")
|
|
cur.execute(
|
|
"SELECT network::text FROM agent_zone_network_ranges WHERE agent_zone_id=%s ORDER BY network,id FOR SHARE",
|
|
(row["agent_zone_id"],),
|
|
)
|
|
row["networks"] = [v["network"] for v in cur.fetchall()]
|
|
if not row["networks"]:
|
|
reject("discovery_network_unassigned")
|
|
row["authority"] = digest(
|
|
{
|
|
k: row[k]
|
|
for k in (
|
|
"agent_id",
|
|
"site_id",
|
|
"agent_zone_id",
|
|
"credential_created_at",
|
|
"zone_version",
|
|
"networks",
|
|
)
|
|
}
|
|
)
|
|
return row
|
|
|
|
@staticmethod
|
|
def in_network(ctx, ip):
|
|
ip = address(ip)
|
|
if not any(
|
|
ipaddress.ip_address(ip) in ipaddress.ip_network(n) for n in ctx["networks"]
|
|
):
|
|
reject("discovery_address_outside_zone")
|
|
|
|
@staticmethod
|
|
def identity(body, agent_id):
|
|
object_body(body)
|
|
if body.get("agent_id") not in (None, "", agent_id):
|
|
reject("discovery_agent_identity_conflict", 403)
|
|
|
|
@staticmethod
|
|
def fingerprint(row):
|
|
return (
|
|
digest(
|
|
{
|
|
k: row.get(k)
|
|
for k in (
|
|
"id",
|
|
"site_id",
|
|
"agent_zone_id",
|
|
"current_mac",
|
|
"current_ip",
|
|
"updated_at",
|
|
)
|
|
}
|
|
)
|
|
if row
|
|
else None
|
|
)
|
|
|
|
def match(self, cur, ctx, item):
|
|
"""All supplied identifiers must agree. No global IP search or ownership claim."""
|
|
if (
|
|
item.get("ip_address")
|
|
and item.get("ip")
|
|
and address(item["ip_address"]) != address(item["ip"])
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
if (
|
|
item.get("mac_address")
|
|
and item.get("mac")
|
|
and mac(item["mac_address"]) != mac(item["mac"])
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
ip = address(item.get("ip_address") or item.get("ip"))
|
|
self.in_network(ctx, ip)
|
|
supplied_mac = mac(item.get("mac_address") or item.get("mac"))
|
|
candidates = []
|
|
if item.get("miner_id"):
|
|
try:
|
|
mid = UUID(str(item["miner_id"]))
|
|
except ValueError:
|
|
reject("discovery_invalid_miner_id", 422)
|
|
cur.execute(
|
|
"SELECT * FROM miners WHERE id=%s AND agent_zone_id=%s FOR UPDATE",
|
|
(mid, ctx["agent_zone_id"]),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
reject("discovery_identity_conflict")
|
|
candidates.append(row)
|
|
if supplied_mac:
|
|
key = supplied_mac.replace(":", "").lower()
|
|
cur.execute(
|
|
"""SELECT m.* FROM miner_mac_ownerships o JOIN miners m ON m.id=o.miner_id
|
|
WHERE o.normalized_mac=%s FOR UPDATE OF m""",
|
|
(key,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
if (
|
|
row["agent_zone_id"] != ctx["agent_zone_id"]
|
|
or mac(row["current_mac"]) != supplied_mac
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
candidates.append(row)
|
|
else:
|
|
cur.execute(
|
|
"""SELECT * FROM miners WHERE agent_zone_id=%s
|
|
AND regexp_replace(lower(current_mac),'[^0-9a-f]','','g')=%s FOR UPDATE""",
|
|
(ctx["agent_zone_id"], key),
|
|
)
|
|
rows = cur.fetchall()
|
|
if len(rows) > 1:
|
|
reject("discovery_identity_ambiguous")
|
|
candidates.extend(rows)
|
|
serial = text(item.get("miner_sn"))
|
|
if serial:
|
|
cur.execute(
|
|
"SELECT * FROM miners WHERE miner_sn=%s ORDER BY id LIMIT 2 FOR UPDATE",
|
|
(serial,),
|
|
)
|
|
serial_rows = cur.fetchall()
|
|
if len(serial_rows) > 1 or any(
|
|
r["agent_zone_id"] != ctx["agent_zone_id"] for r in serial_rows
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
candidates.extend(serial_rows)
|
|
cur.execute(
|
|
"SELECT * FROM miners WHERE agent_zone_id=%s AND host(current_ip)=%s ORDER BY id LIMIT 2 FOR UPDATE",
|
|
(ctx["agent_zone_id"], ip),
|
|
)
|
|
rows = cur.fetchall()
|
|
if len(rows) > 1:
|
|
reject("discovery_identity_ambiguous")
|
|
candidates.extend(rows)
|
|
if len({r["id"] for r in candidates}) > 1:
|
|
reject("discovery_identity_conflict")
|
|
row = candidates[0] if candidates else None
|
|
if row and (
|
|
str(row["current_ip"]) != ip
|
|
or (supplied_mac and mac(row["current_mac"]) != supplied_mac)
|
|
or (serial and row["miner_sn"] != serial)
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
return row
|
|
|
|
@staticmethod
|
|
def observation_version(row):
|
|
return row["updated_at"].isoformat()
|
|
|
|
def observed(self, cur, ctx, item, parent=None):
|
|
ip = address(item.get("ip_address"))
|
|
self.in_network(ctx, ip)
|
|
canonical_mac = mac(item.get("mac_address"))
|
|
if item.get("discovered_at"):
|
|
try:
|
|
reported = datetime.fromisoformat(
|
|
item["discovered_at"].replace("Z", "+00:00")
|
|
)
|
|
if (
|
|
reported.tzinfo is None
|
|
or not -timedelta(minutes=5)
|
|
<= datetime.now(timezone.utc) - reported
|
|
<= TTL
|
|
):
|
|
raise ValueError()
|
|
except (ValueError, AttributeError, TypeError):
|
|
reject("discovery_stale_report", 422)
|
|
# Input is kept verbatim; authoritative provenance exists only in audit_logs.
|
|
oid = str(
|
|
uuid5(
|
|
NAMESPACE_URL,
|
|
"minerops-discovery:" + ctx["authority"] + ":" + digest(item),
|
|
)
|
|
)
|
|
cur.execute(
|
|
"SELECT * FROM minerops_agent_discoveries WHERE id=%s FOR UPDATE", (oid,)
|
|
)
|
|
existing = cur.fetchone()
|
|
if existing:
|
|
return existing
|
|
status = "new"
|
|
reason = None
|
|
matched = None
|
|
try:
|
|
matched = self.match(
|
|
cur,
|
|
ctx,
|
|
{
|
|
**item,
|
|
"ip_address": ip,
|
|
"mac_address": canonical_mac,
|
|
"miner_sn": item.get("miner_sn"),
|
|
},
|
|
)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 409:
|
|
raise
|
|
status = "zone_conflict"
|
|
reason = exc.detail
|
|
if matched:
|
|
status = "known"
|
|
cur.execute(
|
|
"""INSERT INTO minerops_agent_discoveries
|
|
(id,agent_id,agent_zone_id,site_name,room_name,ip_address,mac_address,model,hostname,miner_sn,
|
|
raw_payload,discovery_status,matched_miner_id)
|
|
VALUES (%s,%s,%s,%s,%s,%s::inet,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
|
|
(
|
|
oid,
|
|
ctx["agent_id"],
|
|
ctx["agent_zone_id"],
|
|
ctx["site_name"],
|
|
text(item.get("room_name")),
|
|
ip,
|
|
canonical_mac,
|
|
text(item.get("model")),
|
|
text(item.get("hostname")),
|
|
text(item.get("miner_sn")),
|
|
Jsonb(item),
|
|
status,
|
|
matched["id"] if matched else None,
|
|
),
|
|
)
|
|
row = cur.fetchone()
|
|
self.audit(
|
|
cur,
|
|
"discovery.observed",
|
|
oid,
|
|
{
|
|
"agent_id": ctx["agent_id"],
|
|
"authority": ctx["authority"],
|
|
"payload_hash": digest(item),
|
|
"miner_fingerprint": self.fingerprint(matched),
|
|
"reason": reason,
|
|
"parent_observation_id": parent,
|
|
"version": self.observation_version(row),
|
|
},
|
|
)
|
|
return row
|
|
|
|
def ingest(self, agent_id, body):
|
|
self.identity(body, agent_id)
|
|
items = body.get("discoveries")
|
|
if not isinstance(items, list) or len(items) > MAX_BATCH:
|
|
reject("discovery_invalid_batch", 422)
|
|
if len({address(object_body(i).get("ip_address")) for i in items}) != len(
|
|
items
|
|
):
|
|
reject("discovery_duplicate_batch_address", 422)
|
|
with self.transaction() as cur:
|
|
ctx = self.context(cur, agent_id)
|
|
cur.execute(
|
|
"""SELECT count(*) FROM audit_logs WHERE action='discovery.observed'
|
|
AND new_value_json->>'agent_id'=%s AND created_at>clock_timestamp()-interval '1 minute'""",
|
|
(agent_id,),
|
|
)
|
|
if cur.fetchone()["count"] + len(items) > 1024:
|
|
reject("discovery_rate_limit", 429)
|
|
rows = [self.observed(cur, ctx, object_body(i)) for i in items]
|
|
return {
|
|
"ok": True,
|
|
"received": len(items),
|
|
"inserted": len(rows),
|
|
"matched_existing": sum(r["discovery_status"] == "known" for r in rows),
|
|
"staged_new": sum(r["discovery_status"] == "new" for r in rows),
|
|
"conflicts": sum(
|
|
r["discovery_status"] == "zone_conflict" for r in rows
|
|
),
|
|
"agent_zone_id": str(ctx["agent_zone_id"]),
|
|
"routing_reason": None,
|
|
"errors": [],
|
|
"discoveries": [self.present(r) for r in rows],
|
|
}
|
|
|
|
@staticmethod
|
|
def present(row):
|
|
return {
|
|
**{
|
|
k: (
|
|
str(v)
|
|
if isinstance(
|
|
v,
|
|
(UUID, datetime, ipaddress.IPv4Address, ipaddress.IPv6Address),
|
|
)
|
|
else v
|
|
)
|
|
for k, v in row.items()
|
|
if k != "raw_payload"
|
|
},
|
|
"version": row["updated_at"].isoformat(),
|
|
}
|
|
|
|
def listing(self):
|
|
with self.connection_factory() as conn, conn.cursor(
|
|
row_factory=dict_row
|
|
) as cur:
|
|
cur.execute(
|
|
"SELECT * FROM minerops_agent_discoveries ORDER BY created_at DESC,id LIMIT 100"
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"discoveries": [self.present(r) for r in cur.fetchall()],
|
|
}
|
|
|
|
def observation(self, cur, oid, version):
|
|
try:
|
|
UUID(str(oid))
|
|
except (ValueError, TypeError):
|
|
reject("discovery_id_required", 422)
|
|
cur.execute(
|
|
"SELECT * FROM minerops_agent_discoveries WHERE id=%s FOR UPDATE",
|
|
(str(oid),),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
reject("discovery_not_found", 404)
|
|
ctx = self.context(cur, row["agent_id"])
|
|
if row["agent_zone_id"] != ctx["agent_zone_id"]:
|
|
reject("discovery_stale_authority")
|
|
cur.execute(
|
|
"SELECT new_value_json,created_at FROM audit_logs WHERE entity_type='discovery' AND entity_id=%s AND action='discovery.observed' ORDER BY created_at LIMIT 2",
|
|
(str(oid),),
|
|
)
|
|
proofs = cur.fetchall()
|
|
if len(proofs) != 1:
|
|
reject("discovery_unverified_history")
|
|
proof = proofs[0]
|
|
if proof["new_value_json"].get("authority") != ctx["authority"] or proof[
|
|
"new_value_json"
|
|
].get("payload_hash") != digest(row["raw_payload"]):
|
|
reject("discovery_stale_authority")
|
|
if self.observation_version(row) != proof["new_value_json"].get("version"):
|
|
cur.execute(
|
|
"SELECT new_value_json FROM audit_logs WHERE entity_id=%s AND action='discovery.reviewed' ORDER BY created_at DESC LIMIT 1",
|
|
(str(oid),),
|
|
)
|
|
decision = cur.fetchone()
|
|
if not decision:
|
|
reject("discovery_unverified_history")
|
|
saved = decision["new_value_json"]
|
|
response = saved.get("response", {})
|
|
expected_state = {"ignore": "ignored", "non_miner": "non_miner"}.get(
|
|
saved.get("action")
|
|
)
|
|
if expected_state is None:
|
|
expected_state = (
|
|
"known"
|
|
if proof["new_value_json"].get("miner_fingerprint")
|
|
else "added"
|
|
)
|
|
if (
|
|
response.get("version") != self.observation_version(row)
|
|
or response.get("miner_id")
|
|
!= (str(row["matched_miner_id"]) if row["matched_miner_id"] else None)
|
|
or row["discovery_status"] != expected_state
|
|
):
|
|
reject("discovery_unverified_history")
|
|
self.in_network(ctx, str(row["ip_address"]))
|
|
if datetime.now(timezone.utc) - row["created_at"] > TTL:
|
|
reject("discovery_stale_observation")
|
|
cur.execute(
|
|
"""SELECT 1 FROM minerops_agent_discoveries WHERE agent_id=%s AND agent_zone_id=%s
|
|
AND ip_address=%s AND id<>%s AND created_at>=%s LIMIT 1""",
|
|
(
|
|
row["agent_id"],
|
|
row["agent_zone_id"],
|
|
row["ip_address"],
|
|
row["id"],
|
|
row["created_at"],
|
|
),
|
|
)
|
|
if cur.fetchone():
|
|
reject("discovery_superseded_observation")
|
|
return row, ctx, proof["new_value_json"]
|
|
|
|
def review(self, body, user):
|
|
object_body(body)
|
|
oid = body.get("discovery_id")
|
|
version = body.get("version")
|
|
action = body.get("action")
|
|
if not isinstance(action, str) or action not in {
|
|
"add_to_inventory",
|
|
"ignore",
|
|
"non_miner",
|
|
}:
|
|
reject("discovery_invalid_action", 422)
|
|
with self.transaction() as cur:
|
|
row, ctx, proof = self.observation(cur, oid, version)
|
|
cur.execute(
|
|
"SELECT new_value_json FROM audit_logs WHERE entity_id=%s AND action='discovery.reviewed' ORDER BY created_at DESC LIMIT 1",
|
|
(oid,),
|
|
)
|
|
prior = cur.fetchone()
|
|
if prior:
|
|
decision = prior["new_value_json"]
|
|
if (
|
|
decision["request_version"] == version
|
|
and decision["action"] == action
|
|
):
|
|
return decision["response"]
|
|
reject("discovery_already_reviewed")
|
|
if self.observation_version(row) != version:
|
|
reject("discovery_version_conflict")
|
|
if row["discovery_status"] not in {"new", "known"}:
|
|
reject("discovery_not_reviewable")
|
|
matched = self.match(
|
|
cur,
|
|
ctx,
|
|
{
|
|
"ip_address": str(row["ip_address"]),
|
|
"mac_address": row["mac_address"],
|
|
"miner_sn": row["miner_sn"],
|
|
},
|
|
)
|
|
if self.fingerprint(matched) != proof["miner_fingerprint"]:
|
|
reject("discovery_inventory_changed")
|
|
mid = matched["id"] if matched else None
|
|
state = {"ignore": "ignored", "non_miner": "non_miner"}.get(
|
|
action, "known" if mid else "added"
|
|
)
|
|
if action == "add_to_inventory" and not mid:
|
|
if not row["mac_address"]:
|
|
reject("discovery_enrichment_required")
|
|
cur.execute(
|
|
"""INSERT INTO miners (site_id,agent_zone_id,current_mac,current_ip,hostname,miner_sn,model,miner_type,status,notes)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'discovered','Added by audited observation approval') RETURNING id""",
|
|
(
|
|
ctx["site_id"],
|
|
ctx["agent_zone_id"],
|
|
row["mac_address"],
|
|
row["ip_address"],
|
|
row["hostname"],
|
|
row["miner_sn"],
|
|
row["model"],
|
|
row["model"],
|
|
),
|
|
)
|
|
mid = cur.fetchone()["id"]
|
|
cur.execute(
|
|
"UPDATE minerops_agent_discoveries SET discovery_status=%s,matched_miner_id=%s,updated_at=clock_timestamp() WHERE id=%s RETURNING *",
|
|
(state, mid, oid),
|
|
)
|
|
updated = cur.fetchone()
|
|
response = {
|
|
"ok": True,
|
|
"action": action,
|
|
"discovery_id": oid,
|
|
"miner_id": str(mid) if mid else None,
|
|
"version": self.observation_version(updated),
|
|
"message": "Discovery decision saved.",
|
|
}
|
|
self.audit(
|
|
cur,
|
|
"discovery.reviewed",
|
|
oid,
|
|
{
|
|
"agent_id": ctx["agent_id"],
|
|
"agent_zone_id": str(ctx["agent_zone_id"]),
|
|
"action": action,
|
|
"request_version": version,
|
|
"response": response,
|
|
},
|
|
user["id"],
|
|
self.present(row),
|
|
)
|
|
return response
|
|
|
|
def token(self, ctx, row):
|
|
return jwt.encode(
|
|
{
|
|
"purpose": "minerops.discovery.miner",
|
|
"agent_id": ctx["agent_id"],
|
|
"authority": ctx["authority"],
|
|
"miner_id": str(row["id"]),
|
|
"fingerprint": self.fingerprint(row),
|
|
"exp": datetime.now(timezone.utc) + TTL,
|
|
},
|
|
settings.jwt_secret_key,
|
|
algorithm="HS256",
|
|
)
|
|
|
|
def known(self, agent_id):
|
|
with self.transaction() as cur:
|
|
ctx = self.context(cur, agent_id)
|
|
cur.execute(
|
|
"""SELECT m.*,r.name AS room_name FROM miners m LEFT JOIN rooms r
|
|
ON r.id=m.room_id AND r.site_id=m.site_id WHERE m.agent_zone_id=%s
|
|
AND m.current_ip IS NOT NULL ORDER BY m.id FOR SHARE OF m""",
|
|
(ctx["agent_zone_id"],),
|
|
)
|
|
rows = cur.fetchall()
|
|
result = []
|
|
for r in rows:
|
|
self.in_network(ctx, str(r["current_ip"]))
|
|
result.append(
|
|
{
|
|
"miner_id": str(r["id"]),
|
|
"miner_version": self.token(ctx, r),
|
|
"ip_address": str(r["current_ip"]),
|
|
"mac_address": r["current_mac"],
|
|
"room_name": r["room_name"] or ctx["site_name"],
|
|
"model": r["model"],
|
|
"status": r["status"],
|
|
}
|
|
)
|
|
return {"ok": True, "count": len(result), "miners": result}
|
|
|
|
def bound_miner(self, cur, ctx, item):
|
|
object_body(item)
|
|
try:
|
|
proof = jwt.decode(
|
|
item.get("miner_version", ""),
|
|
settings.jwt_secret_key,
|
|
algorithms=["HS256"],
|
|
options={"require": ["exp"]},
|
|
)
|
|
except jwt.PyJWTError:
|
|
reject("discovery_miner_version_required")
|
|
if (
|
|
proof.get("purpose") != "minerops.discovery.miner"
|
|
or proof.get("agent_id") != ctx["agent_id"]
|
|
or proof.get("authority") != ctx["authority"]
|
|
or proof.get("miner_id") != str(item.get("miner_id"))
|
|
):
|
|
reject("discovery_stale_authority")
|
|
row = self.match(cur, ctx, item)
|
|
if not row or proof.get("fingerprint") != self.fingerprint(row):
|
|
reject("discovery_inventory_changed")
|
|
return row
|
|
|
|
def queue(self, body, user, kind="enrich_match_discovery"):
|
|
object_body(body)
|
|
with self.transaction() as cur:
|
|
if kind == "enrich_match_discovery":
|
|
row, ctx, proof = self.observation(
|
|
cur, body.get("discovery_id"), body.get("version")
|
|
)
|
|
if (
|
|
self.observation_version(row) != body.get("version")
|
|
or row["discovery_status"] != "new"
|
|
):
|
|
reject("discovery_not_enrichable")
|
|
item = {
|
|
"ip_address": str(row["ip_address"]),
|
|
"mac_address": row["mac_address"],
|
|
"miner_sn": row["miner_sn"],
|
|
}
|
|
matched = self.match(cur, ctx, item)
|
|
if self.fingerprint(matched) != proof["miner_fingerprint"]:
|
|
reject("discovery_inventory_changed")
|
|
payload = {
|
|
"discovery_id": row["id"],
|
|
"version": body["version"],
|
|
"ip_address": str(row["ip_address"]),
|
|
}
|
|
entity = row["id"]
|
|
else:
|
|
if not isinstance(body.get("agent_id"), str):
|
|
reject("discovery_agent_id_required", 422)
|
|
ctx = self.context(cur, body["agent_id"])
|
|
payload = {}
|
|
entity = None
|
|
if kind != "enrich_match_discovery":
|
|
try:
|
|
payload["request_id"] = str(
|
|
UUID(str(body.get("request_id") or uuid4()))
|
|
)
|
|
except (ValueError, TypeError):
|
|
reject("discovery_request_id_invalid", 422)
|
|
payload.update(
|
|
{
|
|
"schema_version": 1,
|
|
"agent_zone_id": str(ctx["agent_zone_id"]),
|
|
"authority": ctx["authority"],
|
|
}
|
|
)
|
|
# Queue requests are idempotent for the same principal/type/observation version.
|
|
qid = str(
|
|
uuid5(
|
|
NAMESPACE_URL,
|
|
"minerops-discovery-command:"
|
|
+ kind
|
|
+ ":"
|
|
+ digest(payload)
|
|
+ ":"
|
|
+ str(user["id"]),
|
|
)
|
|
)
|
|
cur.execute(
|
|
"""INSERT INTO minerops_agent_command_queue (id,agent_id,command_type,command_status,
|
|
requested_by_user_id,requested_by_username,request_payload) VALUES (%s,%s,%s,'pending',%s,%s,%s)
|
|
ON CONFLICT(id) DO NOTHING RETURNING id""",
|
|
(
|
|
qid,
|
|
ctx["agent_id"],
|
|
kind,
|
|
str(user["id"]),
|
|
user["username"],
|
|
Jsonb(payload),
|
|
),
|
|
)
|
|
if cur.fetchone():
|
|
self.audit(
|
|
cur,
|
|
"discovery.command_queued",
|
|
entity,
|
|
{
|
|
"command_id": qid,
|
|
"agent_id": ctx["agent_id"],
|
|
"type": kind,
|
|
"payload": payload,
|
|
},
|
|
user["id"],
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"command_id": qid,
|
|
"message": "Scoped read command queued.",
|
|
}
|
|
|
|
def validate_command(self, cur, agent_id, command):
|
|
ctx = self.context(cur, agent_id)
|
|
p = command["request_payload"]
|
|
if (
|
|
not isinstance(p, dict)
|
|
or p.get("schema_version") != 1
|
|
or p.get("agent_zone_id") != str(ctx["agent_zone_id"])
|
|
or p.get("authority") != ctx["authority"]
|
|
):
|
|
reject("discovery_command_stale_authority")
|
|
if command["command_type"] == "enrich_match_discovery":
|
|
row, _, proof = self.observation(
|
|
cur, p.get("discovery_id"), p.get("version")
|
|
)
|
|
if (
|
|
row["agent_id"] != agent_id
|
|
or self.observation_version(row) != p["version"]
|
|
or row["discovery_status"] != "new"
|
|
or str(row["ip_address"]) != p.get("ip_address")
|
|
):
|
|
reject("discovery_command_stale_observation")
|
|
if (
|
|
self.fingerprint(
|
|
self.match(
|
|
cur,
|
|
ctx,
|
|
{
|
|
"ip_address": p["ip_address"],
|
|
"mac_address": row["mac_address"],
|
|
"miner_sn": row["miner_sn"],
|
|
},
|
|
)
|
|
)
|
|
!= proof["miner_fingerprint"]
|
|
):
|
|
reject("discovery_inventory_changed")
|
|
return ctx
|
|
|
|
def target(self, agent_id, body):
|
|
self.identity(body, agent_id)
|
|
try:
|
|
UUID(str(body.get("command_id")))
|
|
except (ValueError, TypeError):
|
|
reject("discovery_command_id_required", 422)
|
|
with self.transaction() as cur:
|
|
cur.execute(
|
|
"SELECT * FROM minerops_agent_command_queue WHERE id=%s AND agent_id=%s FOR UPDATE",
|
|
(body.get("command_id"), agent_id),
|
|
)
|
|
command = cur.fetchone()
|
|
if (
|
|
not command
|
|
or command["command_status"] != "claimed"
|
|
or command["command_type"] != "enrich_match_discovery"
|
|
):
|
|
reject("discovery_enrichment_command_required")
|
|
self.validate_command(cur, agent_id, command)
|
|
p = command["request_payload"]
|
|
if p["discovery_id"] != body.get("discovery_id") or p[
|
|
"version"
|
|
] != body.get("version"):
|
|
reject("discovery_command_observation_conflict")
|
|
return {"ok": True, "ip_address": p["ip_address"]}
|
|
|
|
def enrich(self, agent_id, body):
|
|
self.identity(body, agent_id)
|
|
with self.transaction() as cur:
|
|
ctx = self.context(cur, agent_id)
|
|
if body.get("discovery_id"):
|
|
try:
|
|
UUID(str(body.get("command_id")))
|
|
except (ValueError, TypeError):
|
|
reject("discovery_command_id_required", 422)
|
|
row, _, proof = self.observation(
|
|
cur, body["discovery_id"], body.get("version")
|
|
)
|
|
if (
|
|
row["agent_id"] != agent_id
|
|
or row["discovery_status"] != "new"
|
|
or body.get("version") != self.observation_version(row)
|
|
):
|
|
reject("discovery_not_enrichable")
|
|
cur.execute(
|
|
"SELECT * FROM minerops_agent_command_queue WHERE id=%s AND agent_id=%s FOR UPDATE",
|
|
(body.get("command_id"), agent_id),
|
|
)
|
|
command = cur.fetchone()
|
|
if (
|
|
not command
|
|
or command["command_status"] != "claimed"
|
|
or command["command_type"] != "enrich_match_discovery"
|
|
):
|
|
reject("discovery_enrichment_command_required")
|
|
self.validate_command(cur, agent_id, command)
|
|
if command["request_payload"]["discovery_id"] != row["id"]:
|
|
reject("discovery_command_observation_conflict")
|
|
if address(body.get("ip_address")) != str(row["ip_address"]):
|
|
reject("discovery_identity_conflict")
|
|
item = {
|
|
k: body.get(k)
|
|
for k in (
|
|
"ip_address",
|
|
"mac_address",
|
|
"model",
|
|
"hostname",
|
|
"miner_sn",
|
|
)
|
|
}
|
|
item["parent_observation_id"] = row["id"]
|
|
item["command_id"] = command["id"]
|
|
child = self.observed(cur, ctx, item, parent=row["id"])
|
|
return {
|
|
"ok": True,
|
|
"matched": bool(child["matched_miner_id"]),
|
|
"discovery": self.present(child),
|
|
"message": "Enrichment appended; review the new observation.",
|
|
}
|
|
row = self.bound_miner(cur, ctx, body)
|
|
changes = {
|
|
k: text(body.get(k))
|
|
for k in (
|
|
"hostname",
|
|
"model",
|
|
"miner_type",
|
|
"api_version",
|
|
"firmware_version",
|
|
"platform",
|
|
)
|
|
}
|
|
changes = {k: v for k, v in changes.items() if v is not None}
|
|
if changes:
|
|
sql = ", ".join(k + "=%s" for k in changes)
|
|
cur.execute(
|
|
"UPDATE miners SET "
|
|
+ sql
|
|
+ ",updated_at=clock_timestamp() WHERE id=%s AND agent_zone_id=%s RETURNING *",
|
|
(*changes.values(), row["id"], ctx["agent_zone_id"]),
|
|
)
|
|
updated = cur.fetchone()
|
|
else:
|
|
updated = row
|
|
self.audit(
|
|
cur,
|
|
"discovery.miner_enriched",
|
|
row["id"],
|
|
{
|
|
"agent_id": agent_id,
|
|
"changes": changes,
|
|
"raw_version": body.get("raw_version"),
|
|
"raw_miner_info": body.get("raw_miner_info"),
|
|
},
|
|
old={"fingerprint": self.fingerprint(row)},
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"matched": True,
|
|
"miner": {
|
|
k: (
|
|
str(v)
|
|
if isinstance(
|
|
v,
|
|
(
|
|
UUID,
|
|
datetime,
|
|
ipaddress.IPv4Address,
|
|
ipaddress.IPv6Address,
|
|
),
|
|
)
|
|
else v
|
|
)
|
|
for k, v in updated.items()
|
|
},
|
|
"message": "Enrichment saved without changing ownership or history.",
|
|
}
|
|
|
|
def snapshots(self, agent_id, body, kind="miner"):
|
|
self.identity(body, agent_id)
|
|
items = (
|
|
body.get("miners")
|
|
if kind == "miner"
|
|
else body.get("pool_snapshots", body.get("snapshots"))
|
|
)
|
|
if not isinstance(items, list) or len(items) > MAX_BATCH:
|
|
reject("discovery_invalid_batch", 422)
|
|
with self.transaction() as cur:
|
|
ctx = self.context(cur, agent_id)
|
|
for item in items:
|
|
row = self.bound_miner(cur, ctx, item)
|
|
if kind == "miner":
|
|
if type(item.get("online")) is not bool:
|
|
reject("discovery_invalid_online", 422)
|
|
status = "online" if item["online"] else "offline"
|
|
cur.execute(
|
|
"UPDATE miners SET status=%s,last_seen_at=CASE WHEN %s THEN clock_timestamp() ELSE last_seen_at END,updated_at=clock_timestamp() WHERE id=%s AND agent_zone_id=%s",
|
|
(status, item["online"], row["id"], ctx["agent_zone_id"]),
|
|
)
|
|
summary = object_body(
|
|
object_body(item.get("responses") or {}).get("summary") or {}
|
|
)
|
|
raw = summary.get("json")
|
|
if raw is None:
|
|
try:
|
|
raw = json.loads(summary.get("raw") or "{}")
|
|
except (ValueError, TypeError):
|
|
reject("discovery_invalid_summary", 422)
|
|
status_snapshot(cur, row, raw)
|
|
else:
|
|
pools = item.get("pools", [])
|
|
if not isinstance(pools, list) or len(pools) > 32:
|
|
reject("discovery_invalid_pools", 422)
|
|
for p in pools:
|
|
object_body(p)
|
|
pool_snapshot(cur, row, p, agent_id)
|
|
self.audit(
|
|
cur,
|
|
"discovery." + kind + "_snapshot",
|
|
row["id"],
|
|
{"agent_id": agent_id, "payload": item},
|
|
)
|
|
if kind == "miner":
|
|
cur.execute(
|
|
"""INSERT INTO minerops_agent_snapshots(agent_id,agent_zone_id,site_name,miner_count,payload)
|
|
VALUES (%s,%s,%s,%s,%s) RETURNING id,received_at""",
|
|
(
|
|
agent_id,
|
|
ctx["agent_zone_id"],
|
|
ctx["site_name"],
|
|
len(items),
|
|
Jsonb(body),
|
|
),
|
|
)
|
|
receipt = cur.fetchone()
|
|
else:
|
|
receipt = {}
|
|
return {
|
|
"ok": True,
|
|
"applied": len(items),
|
|
"inserted": len(items),
|
|
"matched": len(items),
|
|
"miner_count": len(items),
|
|
"snapshot_id": receipt.get("id"),
|
|
"message": "Scoped telemetry appended.",
|
|
}
|
|
|
|
def api3(self, agent_id, body):
|
|
self.identity(body, agent_id)
|
|
items = body.get("items")
|
|
if not isinstance(items, list) or len(items) > MAX_BATCH:
|
|
reject("discovery_invalid_batch", 422)
|
|
with self.transaction() as cur:
|
|
ctx = self.context(cur, agent_id)
|
|
counts = dict(
|
|
processed=len(items),
|
|
matched=len(items),
|
|
api3_ok=0,
|
|
api3_failed=0,
|
|
miner_updates=0,
|
|
status_rows=0,
|
|
pool_rows=0,
|
|
hashboard_rows=0,
|
|
)
|
|
for item in items:
|
|
object_body(item)
|
|
device = object_body(item.get("device_info") or {})
|
|
message = object_body(device.get("msg") or {})
|
|
network = object_body(message.get("network") or {})
|
|
if network.get("ip") and address(network["ip"]) != address(
|
|
item.get("ip")
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
if network.get("mac") and mac(network["mac"]) != mac(item.get("mac")):
|
|
reject("discovery_identity_conflict")
|
|
row = self.bound_miner(cur, ctx, item)
|
|
if type(item.get("api3_supported")) is not bool:
|
|
reject("discovery_invalid_api3_supported", 422)
|
|
miner = object_body(message.get("miner") or {})
|
|
system = object_body(message.get("system") or {})
|
|
power = object_body(message.get("power") or {})
|
|
reported_serial = text(miner.get("miner-sn"))
|
|
if (
|
|
reported_serial
|
|
and row["miner_sn"]
|
|
and reported_serial != row["miner_sn"]
|
|
):
|
|
reject("discovery_identity_conflict")
|
|
metadata = {
|
|
"model": text(miner.get("type")),
|
|
"miner_type": text(miner.get("type")),
|
|
"api_version": text(system.get("api")),
|
|
"api3_api_version": text(system.get("api")),
|
|
"api3_api_switch": text(system.get("apiswitch")),
|
|
"firmware_version": text(system.get("fwversion")),
|
|
"platform": text(system.get("platform")),
|
|
}
|
|
metadata = {k: v for k, v in metadata.items() if v is not None}
|
|
if metadata:
|
|
assignments = [
|
|
k
|
|
+ (
|
|
"=COALESCE(NULLIF(" + k + ",''),%s)"
|
|
if k in {"model", "miner_type"}
|
|
else "=%s"
|
|
)
|
|
for k in metadata
|
|
]
|
|
cur.execute(
|
|
"UPDATE miners SET "
|
|
+ ",".join(assignments)
|
|
+ " WHERE id=%s AND agent_zone_id=%s",
|
|
(*metadata.values(), row["id"], ctx["agent_zone_id"]),
|
|
)
|
|
counts["api3_ok" if item["api3_supported"] else "api3_failed"] += 1
|
|
counts["miner_updates"] += 1
|
|
cur.execute(
|
|
"UPDATE miners SET api3_supported=%s,api3_last_seen_at=clock_timestamp(),api3_last_error=%s,updated_at=clock_timestamp() WHERE id=%s AND agent_zone_id=%s",
|
|
(
|
|
item["api3_supported"],
|
|
text(item.get("api3_error")),
|
|
row["id"],
|
|
ctx["agent_zone_id"],
|
|
),
|
|
)
|
|
cur.execute(
|
|
"""INSERT INTO miner_api3_enrichment_snapshots
|
|
(miner_id,agent_id,site_name,ip_address,mac_address,api_supported,api_error,device_info,miner_setting,miner_status,fan_setting,custom_data,api_version,api_switch,model,miner_sn,power_sn,psu_model)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
|
(
|
|
row["id"],
|
|
agent_id,
|
|
ctx["site_name"],
|
|
row["current_ip"],
|
|
row["current_mac"],
|
|
item["api3_supported"],
|
|
text(item.get("api3_error")),
|
|
*[
|
|
Jsonb(item.get(k))
|
|
for k in (
|
|
"device_info",
|
|
"miner_setting",
|
|
"miner_status",
|
|
"fan_setting",
|
|
"custom_data",
|
|
)
|
|
],
|
|
metadata.get("api_version"),
|
|
metadata.get("api3_api_switch"),
|
|
metadata.get("model"),
|
|
reported_serial,
|
|
text(power.get("sn")),
|
|
text(power.get("model")),
|
|
),
|
|
)
|
|
status = object_body(item.get("miner_status") or {})
|
|
status_msg = object_body(status.get("msg") or {})
|
|
summary = object_body(status_msg.get("summary") or {})
|
|
if item["api3_supported"] and summary:
|
|
status_snapshot(cur, row, status, api3=True)
|
|
counts["status_rows"] += 1
|
|
pools = status_msg.get("pools") or []
|
|
boards = status_msg.get("edevs") or []
|
|
if (
|
|
not isinstance(pools, list)
|
|
or len(pools) > 32
|
|
or not isinstance(boards, list)
|
|
or len(boards) > 32
|
|
):
|
|
reject("discovery_invalid_telemetry_batch", 422)
|
|
for pool in pools:
|
|
pool_snapshot(cur, row, pool, agent_id)
|
|
counts["pool_rows"] += 1
|
|
for board in boards:
|
|
object_body(board)
|
|
values = {
|
|
"miner_id": row["id"],
|
|
"ip_address": row["current_ip"],
|
|
"mac_address": row["current_mac"],
|
|
"raw_hashboard_json": Jsonb(board),
|
|
}
|
|
for key, column in {
|
|
"slot": "slot",
|
|
"hash-average": "hash_average_ths",
|
|
"factory-hash": "factory_hash_ths",
|
|
"freq": "freq",
|
|
"effective-chips": "effective_chips",
|
|
"chip-temp-min": "chip_temp_min_c",
|
|
"chip-temp-avg": "chip_temp_avg_c",
|
|
"chip-temp-max": "chip_temp_max_c",
|
|
}.items():
|
|
if key in board:
|
|
values[column] = number(
|
|
board[key], column in {"slot", "effective_chips"}
|
|
)
|
|
insert(cur, "miner_api3_hashboard_snapshots", values)
|
|
counts["hashboard_rows"] += 1
|
|
self.audit(
|
|
cur,
|
|
"discovery.api3_snapshot",
|
|
row["id"],
|
|
{"agent_id": agent_id, "payload": item},
|
|
)
|
|
return {
|
|
"data": {"ok": True, **counts},
|
|
"meta": {},
|
|
"error": None,
|
|
}
|