Agent heartbeats now carry an applied-topology snapshot. The master heartbeat handler compares the reported version_hash against what canonical_hash yields for the hydrated topology pinned to that host and flags Topology.needs_resync on divergence (or when the agent reports no topology at all while master expects one). The mutator watch loop gains reconcile_agent_resyncs, which re-pushes the current hydrated blob via AgentClient.apply_topology without touching status, then clears the flag on success. Push failures leave the flag set so the next tick retries.
380 lines
12 KiB
Python
380 lines
12 KiB
Python
from abc import ABC, abstractmethod
|
|
from typing import Any, Optional
|
|
|
|
|
|
class BaseRepository(ABC):
|
|
"""Abstract base class for DECNET web dashboard data storage."""
|
|
|
|
@abstractmethod
|
|
async def initialize(self) -> None:
|
|
"""Initialize the database schema."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def add_log(self, log_data: dict[str, Any]) -> None:
|
|
"""Add a new log entry to the database."""
|
|
pass
|
|
|
|
async def add_logs(self, log_entries: list[dict[str, Any]]) -> None:
|
|
"""Bulk-insert log entries in a single transaction.
|
|
|
|
Default implementation falls back to per-row add_log; concrete
|
|
repositories should override for a real single-commit insert.
|
|
"""
|
|
for _entry in log_entries:
|
|
await self.add_log(_entry)
|
|
|
|
@abstractmethod
|
|
async def get_logs(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
search: Optional[str] = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Retrieve paginated log entries."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_total_logs(self, search: Optional[str] = None) -> int:
|
|
"""Retrieve the total count of logs, optionally filtered by search."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_stats_summary(self) -> dict[str, Any]:
|
|
"""Retrieve high-level dashboard metrics."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_deckies(self) -> list[dict[str, Any]]:
|
|
"""Retrieve the list of currently deployed deckies."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
|
"""Retrieve a user by their username."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_user_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
|
"""Retrieve a user by their UUID."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def create_user(self, user_data: dict[str, Any]) -> None:
|
|
"""Create a new dashboard user."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None:
|
|
"""Update a user's password and change the must_change_password flag."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def list_users(self) -> list[dict[str, Any]]:
|
|
"""Retrieve all users (caller must strip password_hash before returning to clients)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def delete_user(self, uuid: str) -> bool:
|
|
"""Delete a user by UUID. Returns True if user was found and deleted."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_user_role(self, uuid: str, role: str) -> None:
|
|
"""Update a user's role."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def purge_logs_and_bounties(self) -> dict[str, int]:
|
|
"""Delete all logs, bounties, and attacker profiles. Returns counts of deleted rows."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def add_bounty(self, bounty_data: dict[str, Any]) -> None:
|
|
"""Add a new harvested artifact (bounty) to the database."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_bounties(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
bounty_type: Optional[str] = None,
|
|
search: Optional[str] = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Retrieve paginated bounty entries."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int:
|
|
"""Retrieve the total count of bounties, optionally filtered."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
|
|
"""Retrieve a specific state entry by key."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def set_state(self, key: str, value: Any) -> None:
|
|
"""Store a specific state entry by key."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_max_log_id(self) -> int:
|
|
"""Return the highest log ID, or 0 if the table is empty."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_logs_after_id(self, last_id: int, limit: int = 500) -> list[dict[str, Any]]:
|
|
"""Return logs with id > last_id, ordered by id ASC, up to limit."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_all_bounties_by_ip(self) -> dict[str, list[dict[str, Any]]]:
|
|
"""Retrieve all bounty rows grouped by attacker_ip."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_bounties_for_ips(self, ips: set[str]) -> dict[str, list[dict[str, Any]]]:
|
|
"""Retrieve bounty rows grouped by attacker_ip, filtered to only the given IPs."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def upsert_attacker(self, data: dict[str, Any]) -> str:
|
|
"""Insert or replace an attacker profile record. Returns the row's UUID."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def upsert_attacker_behavior(self, attacker_uuid: str, data: dict[str, Any]) -> None:
|
|
"""Insert or replace the behavioral/fingerprint row for an attacker."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_attacker_behavior(self, attacker_uuid: str) -> Optional[dict[str, Any]]:
|
|
"""Retrieve the behavioral/fingerprint row for an attacker UUID."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_behaviors_for_ips(self, ips: set[str]) -> dict[str, dict[str, Any]]:
|
|
"""Bulk-fetch behavior rows keyed by attacker IP (JOIN to attackers)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_attacker_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
|
"""Retrieve a single attacker profile by UUID."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_attackers(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
search: Optional[str] = None,
|
|
sort_by: str = "recent",
|
|
service: Optional[str] = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Retrieve paginated attacker profile records."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_total_attackers(self, search: Optional[str] = None, service: Optional[str] = None) -> int:
|
|
"""Retrieve the total count of attacker profile records, optionally filtered."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_attacker_commands(
|
|
self,
|
|
uuid: str,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
service: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
"""Retrieve paginated commands for an attacker, optionally filtered by service."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_attacker_artifacts(self, uuid: str) -> list[dict[str, Any]]:
|
|
"""Return `file_captured` log rows for this attacker, newest first."""
|
|
pass
|
|
|
|
# ------------------------------------------------------------- swarm
|
|
# Swarm methods have default no-op / empty implementations so existing
|
|
# subclasses and non-swarm deployments continue to work without change.
|
|
|
|
async def add_swarm_host(self, data: dict[str, Any]) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def get_swarm_host_by_name(self, name: str) -> Optional[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def get_swarm_host_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def get_swarm_host_by_fingerprint(self, fingerprint: str) -> Optional[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def list_swarm_hosts(self, status: Optional[str] = None) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def update_swarm_host(self, uuid: str, fields: dict[str, Any]) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def delete_swarm_host(self, uuid: str) -> bool:
|
|
raise NotImplementedError
|
|
|
|
async def upsert_decky_shard(self, data: dict[str, Any]) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def list_decky_shards(self, host_uuid: Optional[str] = None) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def delete_decky_shards_for_host(self, host_uuid: str) -> int:
|
|
raise NotImplementedError
|
|
|
|
async def delete_decky_shard(self, decky_name: str) -> bool:
|
|
raise NotImplementedError
|
|
|
|
# ----------------------------------------------------------- mazenet
|
|
# MazeNET topology persistence. Default no-op / NotImplementedError so
|
|
# non-default backends stay functional; SQLModelRepository provides the
|
|
# real implementation used by SQLite and MySQL.
|
|
|
|
async def create_topology(self, data: dict[str, Any]) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def get_topology(self, topology_id: str) -> Optional[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def list_topologies(
|
|
self,
|
|
status: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
offset: Optional[int] = None,
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def count_topologies(self, status: Optional[str] = None) -> int:
|
|
raise NotImplementedError
|
|
|
|
async def update_topology_status(
|
|
self,
|
|
topology_id: str,
|
|
new_status: str,
|
|
reason: Optional[str] = None,
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def delete_topology_cascade(self, topology_id: str) -> bool:
|
|
raise NotImplementedError
|
|
|
|
async def set_topology_resync(self, topology_id: str, value: bool) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def list_topologies_needing_resync(self) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def add_lan(self, data: dict[str, Any]) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def update_lan(
|
|
self,
|
|
lan_id: str,
|
|
fields: dict[str, Any],
|
|
*,
|
|
expected_version: Optional[int] = None,
|
|
enforce_pending: bool = False,
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def list_lans_for_topology(
|
|
self, topology_id: str
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def add_topology_decky(self, data: dict[str, Any]) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def update_topology_decky(
|
|
self,
|
|
decky_uuid: str,
|
|
fields: dict[str, Any],
|
|
*,
|
|
expected_version: Optional[int] = None,
|
|
enforce_pending: bool = False,
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def list_topology_deckies(
|
|
self, topology_id: str
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def add_topology_edge(self, data: dict[str, Any]) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def list_topology_edges(
|
|
self, topology_id: str
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def list_topology_status_events(
|
|
self, topology_id: str, limit: int = 100
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
# -------------------- pre-deploy (pending-only) mutations --------------------
|
|
|
|
async def delete_lan(
|
|
self, lan_id: str, *, expected_version: Optional[int] = None
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def delete_topology_decky(
|
|
self, decky_uuid: str, *, expected_version: Optional[int] = None
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def delete_topology_edge(
|
|
self, edge_id: str, *, expected_version: Optional[int] = None
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
# -------------------- live mutation queue (reconciler) --------------------
|
|
|
|
async def enqueue_topology_mutation(
|
|
self,
|
|
topology_id: str,
|
|
op: str,
|
|
payload: dict[str, Any],
|
|
*,
|
|
expected_version: Optional[int] = None,
|
|
) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def claim_next_mutation(
|
|
self, topology_id: str
|
|
) -> Optional[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def mark_mutation_applied(self, mutation_id: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def mark_mutation_failed(
|
|
self, mutation_id: str, reason: str
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def list_topology_mutations(
|
|
self,
|
|
topology_id: str,
|
|
state: Optional[str] = None,
|
|
) -> list[dict[str, Any]]:
|
|
raise NotImplementedError
|
|
|
|
async def has_pending_topology_mutation(self) -> bool:
|
|
return False
|
|
|
|
async def list_live_topology_ids(self) -> list[str]:
|
|
return []
|