From d47a84c90bc7ef54b2358051964452c9cc069f62 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 21:55:41 -0400 Subject: [PATCH] refactor(models): split models.py into topical submodules decnet/web/db/models.py was approaching 1000 lines across User/Log/ Attacker/Swarm/Topology/Workers/Updater/Health domains. Split into a package with one module per domain; __init__.py re-exports every symbol so all 52 call sites keep importing from decnet.web.db.models unchanged. --- decnet/web/db/models.py | 1014 ----------------------------- decnet/web/db/models/__init__.py | 214 ++++++ decnet/web/db/models/_base.py | 23 + decnet/web/db/models/attackers.py | 150 +++++ decnet/web/db/models/auth.py | 73 +++ decnet/web/db/models/deploy.py | 19 + decnet/web/db/models/health.py | 14 + decnet/web/db/models/logs.py | 69 ++ decnet/web/db/models/swarm.py | 200 ++++++ decnet/web/db/models/topology.py | 422 ++++++++++++ decnet/web/db/models/updater.py | 73 +++ decnet/web/db/models/workers.py | 50 ++ 12 files changed, 1307 insertions(+), 1014 deletions(-) delete mode 100644 decnet/web/db/models.py create mode 100644 decnet/web/db/models/__init__.py create mode 100644 decnet/web/db/models/_base.py create mode 100644 decnet/web/db/models/attackers.py create mode 100644 decnet/web/db/models/auth.py create mode 100644 decnet/web/db/models/deploy.py create mode 100644 decnet/web/db/models/health.py create mode 100644 decnet/web/db/models/logs.py create mode 100644 decnet/web/db/models/swarm.py create mode 100644 decnet/web/db/models/topology.py create mode 100644 decnet/web/db/models/updater.py create mode 100644 decnet/web/db/models/workers.py diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py deleted file mode 100644 index b3835c3d..00000000 --- a/decnet/web/db/models.py +++ /dev/null @@ -1,1014 +0,0 @@ -from datetime import datetime, timezone -from typing import Dict, Literal, Optional, Any, List, Annotated -from uuid import uuid4 -from sqlalchemy import Column, Index, Text, UniqueConstraint -from sqlalchemy.dialects.mysql import MEDIUMTEXT -from sqlmodel import SQLModel, Field -from pydantic import BaseModel, ConfigDict, Field as PydanticField, BeforeValidator -from decnet.models import IniContent, DecnetConfig - -# Use on columns that accumulate over an attacker's lifetime (commands, -# fingerprints, state blobs). TEXT on MySQL caps at 64 KiB; MEDIUMTEXT -# stretches to 16 MiB. SQLite has no fixed-width text types so Text() -# stays unchanged there. -_BIG_TEXT = Text().with_variant(MEDIUMTEXT(), "mysql") - -def _normalize_null(v: Any) -> Any: - if isinstance(v, str) and v.lower() in ("null", "undefined", ""): - return None - return v - -NullableDatetime = Annotated[Optional[datetime], BeforeValidator(_normalize_null)] -NullableString = Annotated[Optional[str], BeforeValidator(_normalize_null)] - -# --- Database Tables (SQLModel) --- - -class User(SQLModel, table=True): - __tablename__ = "users" - uuid: str = Field(primary_key=True) - username: str = Field(index=True, unique=True) - password_hash: str - role: str = Field(default="viewer") - must_change_password: bool = Field(default=False) - -class Log(SQLModel, table=True): - __tablename__ = "logs" - id: Optional[int] = Field(default=None, primary_key=True) - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) - decky: str = Field(index=True) - service: str = Field(index=True) - event_type: str = Field(index=True) - attacker_ip: str = Field(index=True) - # Long-text columns — use TEXT so MySQL DDL doesn't truncate to VARCHAR(255). - # TEXT is equivalent to plain text in SQLite. - raw_line: str = Field(sa_column=Column("raw_line", Text, nullable=False)) - fields: str = Field(sa_column=Column("fields", Text, nullable=False)) - msg: Optional[str] = Field(default=None, sa_column=Column("msg", Text, nullable=True)) - # OTEL trace context — bridges the collector→ingester trace to the SSE - # read path. Nullable so pre-existing rows and non-traced deployments - # are unaffected. - trace_id: Optional[str] = Field(default=None) - span_id: Optional[str] = Field(default=None) - -class Bounty(SQLModel, table=True): - __tablename__ = "bounty" - id: Optional[int] = Field(default=None, primary_key=True) - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) - decky: str = Field(index=True) - service: str = Field(index=True) - attacker_ip: str = Field(index=True) - bounty_type: str = Field(index=True) - payload: str = Field(sa_column=Column("payload", Text, nullable=False)) - - -class State(SQLModel, table=True): - __tablename__ = "state" - key: str = Field(primary_key=True) - # JSON-serialized DecnetConfig or other state blobs — can be large as - # deckies/services accumulate. MEDIUMTEXT on MySQL (16 MiB ceiling). - value: str = Field(sa_column=Column("value", _BIG_TEXT, nullable=False)) - - -class Attacker(SQLModel, table=True): - __tablename__ = "attackers" - uuid: str = Field(primary_key=True) - ip: str = Field(index=True) - first_seen: datetime = Field(index=True) - last_seen: datetime = Field(index=True) - event_count: int = Field(default=0) - service_count: int = Field(default=0) - decky_count: int = Field(default=0) - # JSON blobs — these grow over the attacker's lifetime. Use MEDIUMTEXT on - # MySQL (16 MiB) for the fields that accumulate (fingerprints, commands, - # and the deckies/services lists that are unbounded in principle). - services: str = Field( - default="[]", sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]") - ) # JSON list[str] - deckies: str = Field( - default="[]", sa_column=Column("deckies", _BIG_TEXT, nullable=False, default="[]") - ) # JSON list[str], first-contact ordered - traversal_path: Optional[str] = Field( - default=None, sa_column=Column("traversal_path", Text, nullable=True) - ) # "decky-01 → decky-03 → decky-05" - is_traversal: bool = Field(default=False) - bounty_count: int = Field(default=0) - credential_count: int = Field(default=0) - fingerprints: str = Field( - default="[]", sa_column=Column("fingerprints", _BIG_TEXT, nullable=False, default="[]") - ) # JSON list[dict] — bounty fingerprints - commands: str = Field( - default="[]", sa_column=Column("commands", _BIG_TEXT, nullable=False, default="[]") - ) # JSON list[dict] — commands per service/decky - updated_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), index=True - ) - - -class SwarmHost(SQLModel, table=True): - """A worker host enrolled into a DECNET swarm. - - Rows exist only on the master. Populated by `decnet swarm enroll` and - read by the swarm controller when sharding deckies onto workers. - """ - __tablename__ = "swarm_hosts" - uuid: str = Field(primary_key=True) - name: str = Field(index=True, unique=True) - address: str # IP or hostname reachable by the master - agent_port: int = Field(default=8765) - status: str = Field(default="enrolled", index=True) - # ISO-8601 string of the last successful agent /health probe - last_heartbeat: Optional[datetime] = Field(default=None) - client_cert_fingerprint: str # SHA-256 hex of worker's issued client cert - # SHA-256 hex of the updater-identity cert, if the host was enrolled - # with ``--updater`` / ``issue_updater_bundle``. ``None`` for hosts - # that only have an agent identity. - updater_cert_fingerprint: Optional[str] = Field(default=None) - # Directory on the master where the per-worker cert bundle lives - cert_bundle_path: str - enrolled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - notes: Optional[str] = Field(default=None, sa_column=Column("notes", Text, nullable=True)) - # Per-host driver preference. True => deckies on this host run over IPvlan - # (L2) instead of macvlan — required when the host is a VirtualBox guest - # bridged over Wi-Fi, because Wi-Fi APs only allow one MAC per station - # and macvlan's per-container MACs rotate the VM's DHCP lease. - use_ipvlan: bool = Field(default=False) - - -class DeckyShard(SQLModel, table=True): - """Mapping of a single decky to the worker host running it (swarm mode).""" - __tablename__ = "decky_shards" - decky_name: str = Field(primary_key=True) - host_uuid: str = Field(foreign_key="swarm_hosts.uuid", index=True) - # JSON list of service names running on this decky (snapshot of assignment). - services: str = Field(sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]")) - # Full serialised DeckyConfig from the most recent dispatch or heartbeat. - # Lets the dashboard render the same rich card (hostname/distro/archetype/ - # service_config/mutate_interval) that the local-fleet view uses, without - # needing a live round-trip to the worker for every page render. - decky_config: Optional[str] = Field( - default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) - ) - decky_ip: Optional[str] = Field(default=None) - state: str = Field(default="pending", index=True) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed - last_error: Optional[str] = Field(default=None, sa_column=Column("last_error", Text, nullable=True)) - compose_hash: Optional[str] = Field(default=None) - # Timestamp of the last heartbeat that echoed this shard; lets the UI - # show "stale" decks whose agent has gone silent. - last_seen: Optional[datetime] = Field(default=None) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - -class AttackerBehavior(SQLModel, table=True): - """ - Timing & behavioral profile for an attacker, joined to Attacker by uuid. - - Kept in a separate table so the core Attacker row stays narrow and - behavior data can be updated independently (e.g. as the sniffer observes - more packets) without touching the event-count aggregates. - """ - __tablename__ = "attacker_behavior" - attacker_uuid: str = Field(primary_key=True, foreign_key="attackers.uuid") - # OS / TCP stack fingerprint (rolled up from sniffer events) - os_guess: Optional[str] = None - hop_distance: Optional[int] = None - tcp_fingerprint: str = Field( - default="{}", - sa_column=Column("tcp_fingerprint", Text, nullable=False, default="{}"), - ) # JSON: window, wscale, mss, options_sig - # Raw SSH KEX algorithm preference strings observed across HASSH probes - # (one entry per hassh_fingerprint event). Keeping the raw ordered list - # enables post-hoc KEX-order fingerprinting beyond the HASSH hash. - kex_order_raw: Optional[str] = Field( - default=None, - sa_column=Column("kex_order_raw", Text, nullable=True), - ) # JSON list[str] — kex_algorithms comma-separated strings - # Sniffer-observed SSH client identification strings (RFC 4253 §4.2), - # deduped in observation order. Captures the attacker's SSH client - # software (e.g. "SSH-2.0-OpenSSH_9.2p1", "SSH-2.0-libssh2_1.10.0"). - ssh_client_banners: Optional[str] = Field( - default=None, - sa_column=Column("ssh_client_banners", Text, nullable=True), - ) # JSON list[str] - retransmit_count: int = Field(default=0) - # Behavioral (derived by the profiler from log-event timing) - behavior_class: Optional[str] = None # beaconing | interactive | scanning | brute_force | slow_scan | mixed | unknown - beacon_interval_s: Optional[float] = None - beacon_jitter_pct: Optional[float] = None - tool_guesses: Optional[str] = None # JSON list[str] — all matched tools - timing_stats: str = Field( - default="{}", - sa_column=Column("timing_stats", Text, nullable=False, default="{}"), - ) # JSON: mean/median/stdev/min/max IAT - phase_sequence: str = Field( - default="{}", - sa_column=Column("phase_sequence", Text, nullable=False, default="{}"), - ) # JSON: recon_end/exfil_start/latency - updated_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), index=True - ) - - -class SessionProfile(SQLModel, table=True): - """ - Per-session keystroke-dynamics fingerprint. - - One row per recorded interactive session. Pre-v1 the ingestion job - that populates these columns is not yet built (tracked as gap #2 in - SIGNAL_CAPTURE_AUDIT.md); the table ships empty so that: - * downstream correlation/federation work can target a stable schema, and - * `schema_version` is committed to storage from day one — federation - gossip in v2 requires cross-operator compatibility, and retrofitting - a version column after rows exist is painful. - - All feature columns are nullable so the empty write path (one row per - closed session) is valid without the behavioral analyzer online yet. - """ - __tablename__ = "session_profile" - sid: str = Field(primary_key=True) # session UUID - log_id: Optional[int] = Field( - default=None, foreign_key="logs.id", index=True - ) - schema_version: int = Field(default=1) - # Inter-key interval timing moments (seconds). - kd_iki_mean: Optional[float] = None - kd_iki_stdev: Optional[float] = None - kd_iki_p50: Optional[float] = None - kd_iki_p95: Optional[float] = None - kd_enter_latency_p50: Optional[float] = None - kd_enter_latency_p95: Optional[float] = None - # Cadence ratios. - kd_burst_ratio: Optional[float] = None - kd_think_ratio: Optional[float] = None - # Control-character rates (events per keystroke). - kd_ctrl_backspace: Optional[float] = None - kd_ctrl_wkill: Optional[float] = None - kd_ctrl_ukill: Optional[float] = None - kd_ctrl_abort: Optional[float] = None - kd_ctrl_eof: Optional[float] = None - kd_arrow_rate: Optional[float] = None - kd_tab_rate: Optional[float] = None - # 8-byte SimHash over keystroke digraphs — Hamming-comparable across sessions. - kd_digraph_simhash: Optional[bytes] = Field(default=None, index=True) - # Derived totals. - total_keystrokes: Optional[int] = None - session_duration_s: Optional[float] = None - created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc) - ) - - -# --- MazeNET tables --- -# Nested deception topologies: an arbitrary-depth DAG of LANs connected by -# multi-homed "bridge" deckies. Purpose-built; disjoint from DeckyShard which -# remains SWARM-only. - -class Topology(SQLModel, table=True): - __tablename__ = "topologies" - id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - name: str = Field(index=True, unique=True) - mode: str = Field(default="unihost") # unihost|agent - # When ``mode == "agent"``, pins this topology to a specific enrolled - # worker. ``None`` for unihost topologies (master-local deploy). - target_host_uuid: Optional[str] = Field( - default=None, foreign_key="swarm_hosts.uuid", index=True - ) - # Full TopologyConfig snapshot (including seed) used at generation time. - config_snapshot: str = Field( - sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}") - ) - status: str = Field( - default="pending", index=True - ) # pending|deploying|active|degraded|failed|tearing_down|torn_down - status_changed_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc) - ) - created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), index=True - ) - # Optimistic-concurrency token. Bumped by repo methods that mutate - # the topology or any child row when an expected_version is supplied. - # Callers pass their last-seen version; mismatch raises VersionConflict. - version: int = Field(default=1, nullable=False) - # Set by the heartbeat handler when an agent's reported - # ``applied_version_hash`` diverges from what we expect it to be - # running. Drained by the mutator watch loop, which re-pushes via - # AgentClient and clears the flag. NULL for unihost topologies. - needs_resync: bool = Field(default=False, nullable=False) - - -class LAN(SQLModel, table=True): - __tablename__ = "lans" - __table_args__ = (UniqueConstraint("topology_id", "name", name="uq_lan_topology_name"),) - id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - topology_id: str = Field(foreign_key="topologies.id", index=True) - name: str - # Populated after the Docker network is created; nullable before deploy. - docker_network_id: Optional[str] = Field(default=None) - subnet: str - is_dmz: bool = Field(default=False) - # Canvas layout coordinates (set by the web editor). Nullable so - # generator-emitted LANs don't need auto-layout at generation time. - x: Optional[float] = Field(default=None) - y: Optional[float] = Field(default=None) - - -class TopologyDecky(SQLModel, table=True): - """A decky belonging to a MazeNET topology. - - Disjoint from DeckyShard (which is SWARM-only). UUID PK; decky name is - unique only within a topology, so two topologies can both have a - ``decky-01`` without colliding. - """ - __tablename__ = "topology_deckies" - __table_args__ = ( - UniqueConstraint("topology_id", "name", name="uq_topology_decky_name"), - ) - uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - topology_id: str = Field(foreign_key="topologies.id", index=True) - name: str - # JSON list[str] of service names on this decky (snapshot of assignment). - services: str = Field( - sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]") - ) - # Full serialised DeckyConfig snapshot — lets the dashboard render the - # same card shape as DeckyShard without a live round-trip. - decky_config: Optional[str] = Field( - default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) - ) - ip: Optional[str] = Field(default=None) - # Same vocabulary as DeckyShard.state to keep dashboard rendering uniform. - state: str = Field( - default="pending", index=True - ) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed - last_error: Optional[str] = Field( - default=None, sa_column=Column("last_error", Text, nullable=True) - ) - compose_hash: Optional[str] = Field(default=None) - last_seen: Optional[datetime] = Field(default=None) - updated_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc) - ) - # Canvas layout coordinates (set by the web editor). Nullable so - # generator-emitted deckies don't need auto-layout at generation time. - x: Optional[float] = Field(default=None) - y: Optional[float] = Field(default=None) - - -class TopologyEdge(SQLModel, table=True): - """Membership edge: a decky attached to a LAN. - - A decky appearing in ≥2 edges is multi-homed (a bridge decky). - """ - __tablename__ = "topology_edges" - id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - topology_id: str = Field(foreign_key="topologies.id", index=True) - decky_uuid: str = Field(foreign_key="topology_deckies.uuid", index=True) - lan_id: str = Field(foreign_key="lans.id", index=True) - is_bridge: bool = Field(default=False) - forwards_l3: bool = Field(default=False) - - -class TopologyStatusEvent(SQLModel, table=True): - """Append-only audit log of topology status transitions.""" - __tablename__ = "topology_status_events" - id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - topology_id: str = Field(foreign_key="topologies.id", index=True) - from_status: str - to_status: str - at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), index=True - ) - reason: Optional[str] = Field( - default=None, sa_column=Column("reason", Text, nullable=True) - ) - - -class TopologyMutation(SQLModel, table=True): - """Operator-requested live mutation for an active MazeNET topology. - - Each row is one intent (add LAN, attach decky, etc.). The mutator's - reconciler claims ``pending`` rows atomically (see - ``SQLModelRepository.claim_next_mutation``), applies them against - Docker, and writes ``applied`` or ``failed`` back. The ``(state, - topology_id)`` composite index keeps the watch-loop guard query - cheap even with years of mutation history. - """ - __tablename__ = "topology_mutations" - __table_args__ = ( - Index( - "ix_topology_mutations_state_topology", - "state", - "topology_id", - ), - ) - id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) - topology_id: str = Field(foreign_key="topologies.id", index=True) - # add_lan|remove_lan|add_decky|attach_decky|detach_decky| - # remove_decky|update_decky|update_lan - op: str = Field(index=True) - # JSON-serialised op payload (keys depend on ``op``). - payload: str = Field( - sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}") - ) - # pending|applying|applied|failed - state: str = Field(default="pending", index=True) - requested_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), index=True - ) - applied_at: Optional[datetime] = Field(default=None) - reason: Optional[str] = Field( - default=None, sa_column=Column("reason", Text, nullable=True) - ) - - -# --- API Request/Response Models (Pydantic) --- - -class Token(BaseModel): - access_token: str - token_type: str - must_change_password: bool = False - -class LoginRequest(BaseModel): - username: str - password: str = PydanticField(..., max_length=72) - -class ChangePasswordRequest(BaseModel): - old_password: str = PydanticField(..., max_length=72) - new_password: str = PydanticField(..., max_length=72) - -class LogsResponse(BaseModel): - total: int - limit: int - offset: int - data: List[dict[str, Any]] - -class BountyResponse(BaseModel): - total: int - limit: int - offset: int - data: List[dict[str, Any]] - -class AttackersResponse(BaseModel): - total: int - limit: int - offset: int - data: List[dict[str, Any]] - -class StatsResponse(BaseModel): - total_logs: int - unique_attackers: int - active_deckies: int - deployed_deckies: int - -class MutateIntervalRequest(BaseModel): - # Human-readable duration: where unit is m(inutes), d(ays), M(onths), y/Y(ears). - # Minimum granularity is 1 minute. Seconds are not accepted. - mutate_interval: Optional[str] = PydanticField(None, pattern=r"^[1-9]\d*[mdMyY]$") - -class DeployIniRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - # This field now enforces strict INI structure during Pydantic initialization. - # The OpenAPI schema correctly shows it as a required string. - ini_content: IniContent = PydanticField(..., description="A valid INI formatted string") - - -# --- Configuration Models --- - -class CreateUserRequest(BaseModel): - username: str = PydanticField(..., min_length=1, max_length=64) - password: str = PydanticField(..., min_length=8, max_length=72) - role: Literal["admin", "viewer"] = "viewer" - -class UpdateUserRoleRequest(BaseModel): - role: Literal["admin", "viewer"] - -class ResetUserPasswordRequest(BaseModel): - new_password: str = PydanticField(..., min_length=8, max_length=72) - -class DeploymentLimitRequest(BaseModel): - deployment_limit: int = PydanticField(..., ge=1, le=500) - -class GlobalMutationIntervalRequest(BaseModel): - global_mutation_interval: str = PydanticField(..., pattern=r"^[1-9]\d*[mdMyY]$") - -class UserResponse(BaseModel): - uuid: str - username: str - role: str - must_change_password: bool - -class ConfigResponse(BaseModel): - role: str - deployment_limit: int - global_mutation_interval: str - -class AdminConfigResponse(ConfigResponse): - users: List[UserResponse] - - -class ComponentHealth(BaseModel): - status: Literal["ok", "failing"] - detail: Optional[str] = None - - -class HealthResponse(BaseModel): - status: Literal["healthy", "degraded", "unhealthy"] - components: dict[str, ComponentHealth] - - -# --- Workers panel (Config → Workers) --- -# Bus-backed health + control: workers heartbeat on ``system..health`` -# and listen on ``system..control``. The API aggregates last-seen -# heartbeats via the worker registry; these are the HTTP-facing shapes. - -class WorkerStatus(BaseModel): - name: str - # ``ok`` — heartbeat within 90s (3× 30s heartbeat interval) - # ``stale`` — worker was seen before but hasn't pulsed in 90s+ - # ``unknown`` — we've never received a heartbeat from this name - status: Literal["ok", "stale", "unknown"] - last_heartbeat_ts: Optional[float] = None - seconds_since: Optional[float] = None - # Whatever the worker's ``extra()`` callback put in the heartbeat; - # opaque to the panel, displayed only if the UI knows the key. - extra: Dict[str, Any] = PydanticField(default_factory=dict) - # True iff a ``decnet-.service`` unit file is present on the - # host. False flips the UI START button to disabled with a - # "Unit not installed" tooltip. Default True for backwards compat - # on clients that pre-date the field. - installed: bool = True - - -class WorkersResponse(BaseModel): - workers: List[WorkerStatus] - generated_at: float - bus_connected: bool - - -class WorkerControlResponse(BaseModel): - accepted: bool - worker: str - action: str - - -class StartFailure(BaseModel): - name: str - reason: str - - -class StartAllResponse(BaseModel): - started: List[str] - already_running: List[str] - failed: List[StartFailure] - - -# --- Swarm API DTOs --- -# Request/response contracts for the master-side swarm controller -# (decnet/web/swarm_api.py). The underlying SQLModel tables — SwarmHost and -# DeckyShard — live above; these are the HTTP-facing shapes. - -class SwarmEnrollRequest(BaseModel): - # x509 CommonName is capped at 64 bytes (RFC 5280 UB-common-name) — the - # cert issuer would reject anything longer with a ValueError. - # Pattern: ASCII hostname-safe characters only. The name is embedded - # both in the CN and as a SAN DNS entry; x509.DNSName only accepts - # A-label ASCII, so non-ASCII would blow up at issuance. - name: str = PydanticField( - ..., min_length=1, max_length=64, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._\-]*$", - ) - address: str = PydanticField( - ..., min_length=1, max_length=253, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._:\-]*$", - description="IP or DNS the master uses to reach the worker", - ) - agent_port: int = PydanticField(default=8765, ge=1, le=65535) - sans: list[ - Annotated[ - str, - PydanticField( - min_length=1, max_length=253, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._:\-]*$", - ), - ] - ] = PydanticField( - default_factory=list, - description="Extra SANs (IPs / hostnames) to embed in the worker cert", - ) - notes: Optional[str] = None - issue_updater_bundle: bool = PydanticField( - default=False, - description="If true, also issue an updater cert (CN=updater@) for the remote self-updater", - ) - - -class SwarmUpdaterBundle(BaseModel): - """Subset of SwarmEnrolledBundle for the updater identity.""" - fingerprint: str - updater_cert_pem: str - updater_key_pem: str - - -class SwarmEnrolledBundle(BaseModel): - """Cert bundle returned to the operator — must be delivered to the worker.""" - host_uuid: str - name: str - address: str - agent_port: int - fingerprint: str - ca_cert_pem: str - worker_cert_pem: str - worker_key_pem: str - updater: Optional[SwarmUpdaterBundle] = None - - -class SwarmHostView(BaseModel): - uuid: str - name: str - address: str - agent_port: int - status: str - last_heartbeat: Optional[datetime] = None - client_cert_fingerprint: str - updater_cert_fingerprint: Optional[str] = None - enrolled_at: datetime - notes: Optional[str] = None - use_ipvlan: bool = False - - -class DeckyShardView(BaseModel): - """One decky → host mapping, enriched with the host's identity for display.""" - decky_name: str - decky_ip: Optional[str] = None # resolved from the stored DecnetConfig at read time - host_uuid: str - host_name: str - host_address: str - host_status: str - services: list[str] - state: str - last_error: Optional[str] = None - compose_hash: Optional[str] = None - updated_at: datetime - # Enriched fields lifted from the stored DeckyConfig snapshot so the - # dashboard can render the same card shape as the local-fleet view. - hostname: Optional[str] = None - distro: Optional[str] = None - archetype: Optional[str] = None - service_config: dict[str, dict[str, Any]] = {} - mutate_interval: Optional[int] = None - last_mutated: float = 0.0 - last_seen: Optional[datetime] = None - - -class SwarmDeployRequest(BaseModel): - config: DecnetConfig - dry_run: bool = False - no_cache: bool = False - - -class SwarmTeardownRequest(BaseModel): - host_uuid: Optional[str] = PydanticField( - default=None, - description="If set, tear down only this worker; otherwise tear down all hosts", - ) - decky_id: Optional[str] = None - - -class SwarmHostResult(BaseModel): - host_uuid: str - host_name: str - ok: bool - detail: Any | None = None - - -class SwarmDeployResponse(BaseModel): - results: list[SwarmHostResult] - - -class SwarmHostHealth(BaseModel): - host_uuid: str - name: str - address: str - reachable: bool - detail: Any | None = None - - -class SwarmCheckResponse(BaseModel): - results: list[SwarmHostHealth] - - -# --- Remote Updates (master → worker /updater) DTOs --- -# Powers the dashboard's Remote Updates page. The master dashboard calls -# these (auth-gated) endpoints; internally they fan out to each worker's -# updater daemon over mTLS via UpdaterClient. - -class HostReleaseInfo(BaseModel): - host_uuid: str - host_name: str - address: str - reachable: bool - # These fields mirror the updater's /health payload when reachable; they - # are all Optional so an unreachable host still serializes cleanly. - agent_status: Optional[str] = None - current_sha: Optional[str] = None - previous_sha: Optional[str] = None - releases: list[dict[str, Any]] = PydanticField(default_factory=list) - detail: Optional[str] = None # populated when unreachable - - -class HostReleasesResponse(BaseModel): - hosts: list[HostReleaseInfo] - - -class PushUpdateRequest(BaseModel): - host_uuids: Optional[list[str]] = PydanticField( - default=None, - description="Target specific hosts; mutually exclusive with 'all'.", - ) - all: bool = PydanticField(default=False, description="Target every non-decommissioned host with an updater bundle.") - include_self: bool = PydanticField( - default=False, - description="After a successful /update, also push /update-self to upgrade the updater itself.", - ) - exclude: list[str] = PydanticField( - default_factory=list, - description="Additional tarball exclude globs (on top of the built-in defaults).", - ) - - -class PushUpdateResult(BaseModel): - host_uuid: str - host_name: str - # updated = /update 200. rolled-back = /update 409 (auto-recovered). - # failed = transport error or non-200/409 response. self-updated = /update-self succeeded. - status: Literal["updated", "rolled-back", "failed", "self-updated", "self-failed"] - http_status: Optional[int] = None - sha: Optional[str] = None - detail: Optional[str] = None - stderr: Optional[str] = None - - -class PushUpdateResponse(BaseModel): - sha: str - tarball_bytes: int - results: list[PushUpdateResult] - - -class RollbackRequest(BaseModel): - host_uuid: str = PydanticField(..., description="Host to roll back to its previous release slot.") - - -class RollbackResponse(BaseModel): - host_uuid: str - host_name: str - status: Literal["rolled-back", "failed"] - http_status: Optional[int] = None - detail: Optional[str] = None - - -# --- MazeNET Topology REST DTOs (phase 3) --- -# Request/response shapes for /api/v1/topologies. All write paths are -# admin-only; reads accept admin or viewer. Child CRUD is pending-only; -# mutations of active|degraded topologies go through the queue. - - -class TopologyGenerateRequest(BaseModel): - """Body for POST /topologies — mirrors the `topology generate` CLI.""" - name: str = PydanticField(..., min_length=1, max_length=64) - mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$") - target_host_uuid: Optional[str] = None - depth: int = PydanticField(..., ge=1, le=16) - branching_factor: int = PydanticField(..., ge=1, le=8) - deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32) - deckies_per_lan_max: int = PydanticField(default=3, ge=1, le=32) - bridge_forward_probability: float = PydanticField(default=1.0, ge=0.0, le=1.0) - cross_edge_probability: float = PydanticField(default=0.0, ge=0.0, le=1.0) - services_explicit: Optional[list[str]] = None - randomize_services: bool = True - seed: Optional[int] = PydanticField(default=None, ge=0) - - -class TopologySummary(BaseModel): - """List-row shape for GET /topologies.""" - model_config = ConfigDict(extra="ignore") - id: str - name: str - mode: str - target_host_uuid: Optional[str] = None - status: str - version: int - needs_resync: bool = False - created_at: datetime - status_changed_at: Optional[datetime] = None - - -class TopologyListResponse(BaseModel): - total: int - limit: Optional[int] = None - offset: Optional[int] = None - data: list[TopologySummary] - - -class LANRow(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str - topology_id: str - name: str - subnet: str - is_dmz: bool = False - docker_network_id: Optional[str] = None - x: Optional[float] = None - y: Optional[float] = None - - -class DeckyRow(BaseModel): - model_config = ConfigDict(extra="ignore") - uuid: str - topology_id: str - name: str - services: list[str] = PydanticField(default_factory=list) - decky_config: Optional[dict[str, Any]] = None - ip: Optional[str] = None - state: str - last_error: Optional[str] = None - x: Optional[float] = None - y: Optional[float] = None - - -class EdgeRow(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str - topology_id: str - decky_uuid: str - lan_id: str - is_bridge: bool = False - forwards_l3: bool = False - - -class TopologyDetail(BaseModel): - """Hydrated topology — mirrors persistence.hydrate() output. - - ``topology`` uses :class:`TopologySummary` which already exposes - ``target_host_uuid`` — agent-targeted topologies surface their - pinned host through that field. - """ - topology: TopologySummary - lans: list[LANRow] - deckies: list[DeckyRow] - edges: list[EdgeRow] - - -class TopologyStatusEventRow(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str - topology_id: str - from_status: str - to_status: str - at: datetime - reason: Optional[str] = None - - -class LANCreateRequest(BaseModel): - name: str = PydanticField(..., min_length=1, max_length=64) - subnet: Optional[str] = None - is_dmz: bool = False - x: Optional[float] = None - y: Optional[float] = None - expected_version: Optional[int] = None - - -class LANUpdateRequest(BaseModel): - name: Optional[str] = None - subnet: Optional[str] = None - is_dmz: Optional[bool] = None - x: Optional[float] = None - y: Optional[float] = None - expected_version: Optional[int] = None - - -class DeckyCreateRequest(BaseModel): - name: str = PydanticField(..., min_length=1, max_length=64) - services: list[str] = PydanticField(default_factory=list) - decky_config: Optional[dict[str, Any]] = None - x: Optional[float] = None - y: Optional[float] = None - expected_version: Optional[int] = None - - -class DeckyUpdateRequest(BaseModel): - name: Optional[str] = None - services: Optional[list[str]] = None - decky_config: Optional[dict[str, Any]] = None - x: Optional[float] = None - y: Optional[float] = None - expected_version: Optional[int] = None - - -class EdgeCreateRequest(BaseModel): - decky_uuid: str - lan_id: str - is_bridge: bool = False - forwards_l3: bool = False - expected_version: Optional[int] = None - - -_MUTATION_OPS = Literal[ - "add_lan", - "remove_lan", - "add_decky", - "attach_decky", - "detach_decky", - "remove_decky", - "update_decky", - "update_lan", -] - - -class MutationEnqueueRequest(BaseModel): - op: _MUTATION_OPS - payload: dict[str, Any] = PydanticField(default_factory=dict) - expected_version: Optional[int] = None - - -def _decode_json_payload(v: Any) -> Any: - """Accept either a dict or a JSON-encoded string for mutation payloads.""" - if isinstance(v, str): - import json as _json - return _json.loads(v) if v else {} - return v - - -_MutationPayload = Annotated[dict[str, Any], BeforeValidator(_decode_json_payload)] - - -class MutationRow(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str - topology_id: str - op: str - payload: _MutationPayload = PydanticField(default_factory=dict) - state: str - requested_at: datetime - applied_at: Optional[datetime] = None - reason: Optional[str] = None - - -class MutationEnqueueResponse(BaseModel): - mutation_id: str - state: str = "pending" - - -class ValidationIssueResponse(BaseModel): - severity: str - code: str - message: str - target: dict[str, Any] = PydanticField(default_factory=dict) - - -class ValidationErrorResponse(BaseModel): - detail: str = "Topology validation failed" - issues: list[ValidationIssueResponse] - - -class VersionConflictResponse(BaseModel): - detail: str = "Topology version conflict" - current: int - expected: int - - -class NotEditableResponse(BaseModel): - detail: str = "Topology not editable" - status: str - reason: Optional[str] = None - - -class ServiceCatalogResponse(BaseModel): - services: list[str] - - -class ArchetypeEntry(BaseModel): - slug: str - display_name: str - description: str - services: list[str] - preferred_distros: list[str] - nmap_os: str - - -class ArchetypeCatalogResponse(BaseModel): - archetypes: list[ArchetypeEntry] - - -class NextIPResponse(BaseModel): - subnet: str - ip: str - - -class NextSubnetResponse(BaseModel): - subnet: str - - -class DeployAcceptedResponse(BaseModel): - topology_id: str - status: str - dry_run: bool = False diff --git a/decnet/web/db/models/__init__.py b/decnet/web/db/models/__init__.py new file mode 100644 index 00000000..ae796f90 --- /dev/null +++ b/decnet/web/db/models/__init__.py @@ -0,0 +1,214 @@ +""" +Database tables (SQLModel) and HTTP request/response shapes (Pydantic). + +Split into topical modules for readability, but every symbol is re-exported +from this package so ``from decnet.web.db.models import X`` keeps working +everywhere — no importer needs to know which submodule a class lives in. +""" +from ._base import ( + NullableDatetime, + NullableString, + _BIG_TEXT, + _normalize_null, +) +from .auth import ( + AdminConfigResponse, + ChangePasswordRequest, + ConfigResponse, + CreateUserRequest, + DeploymentLimitRequest, + GlobalMutationIntervalRequest, + LoginRequest, + ResetUserPasswordRequest, + Token, + UpdateUserRoleRequest, + User, + UserResponse, +) +from .attackers import ( + Attacker, + AttackerBehavior, + AttackersResponse, + SessionProfile, +) +from .deploy import ( + DeployIniRequest, + MutateIntervalRequest, +) +from .health import ( + ComponentHealth, + HealthResponse, +) +from .logs import ( + Bounty, + BountyResponse, + Log, + LogsResponse, + State, + StatsResponse, +) +from .swarm import ( + DeckyShard, + DeckyShardView, + SwarmCheckResponse, + SwarmDeployRequest, + SwarmDeployResponse, + SwarmEnrolledBundle, + SwarmEnrollRequest, + SwarmHost, + SwarmHostHealth, + SwarmHostResult, + SwarmHostView, + SwarmTeardownRequest, + SwarmUpdaterBundle, +) +from .topology import ( + LAN, + ArchetypeCatalogResponse, + ArchetypeEntry, + DeckyCreateRequest, + DeckyRow, + DeckyUpdateRequest, + DeployAcceptedResponse, + EdgeCreateRequest, + EdgeRow, + LANCreateRequest, + LANRow, + LANUpdateRequest, + MutationEnqueueRequest, + MutationEnqueueResponse, + MutationRow, + NextIPResponse, + NextSubnetResponse, + NotEditableResponse, + ServiceCatalogResponse, + Topology, + TopologyDecky, + TopologyDetail, + TopologyEdge, + TopologyGenerateRequest, + TopologyListResponse, + TopologyMutation, + TopologyStatusEvent, + TopologyStatusEventRow, + TopologySummary, + ValidationErrorResponse, + ValidationIssueResponse, + VersionConflictResponse, +) +from .updater import ( + HostReleaseInfo, + HostReleasesResponse, + PushUpdateRequest, + PushUpdateResponse, + PushUpdateResult, + RollbackRequest, + RollbackResponse, +) +from .workers import ( + StartAllResponse, + StartFailure, + WorkerControlResponse, + WorkersResponse, + WorkerStatus, +) + +__all__ = [ + # _base + "NullableDatetime", + "NullableString", + "_BIG_TEXT", + "_normalize_null", + # auth + "AdminConfigResponse", + "ChangePasswordRequest", + "ConfigResponse", + "CreateUserRequest", + "DeploymentLimitRequest", + "GlobalMutationIntervalRequest", + "LoginRequest", + "ResetUserPasswordRequest", + "Token", + "UpdateUserRoleRequest", + "User", + "UserResponse", + # attackers + "Attacker", + "AttackerBehavior", + "AttackersResponse", + "SessionProfile", + # deploy + "DeployIniRequest", + "MutateIntervalRequest", + # health + "ComponentHealth", + "HealthResponse", + # logs + "Bounty", + "BountyResponse", + "Log", + "LogsResponse", + "State", + "StatsResponse", + # swarm + "DeckyShard", + "DeckyShardView", + "SwarmCheckResponse", + "SwarmDeployRequest", + "SwarmDeployResponse", + "SwarmEnrolledBundle", + "SwarmEnrollRequest", + "SwarmHost", + "SwarmHostHealth", + "SwarmHostResult", + "SwarmHostView", + "SwarmTeardownRequest", + "SwarmUpdaterBundle", + # topology + "LAN", + "ArchetypeCatalogResponse", + "ArchetypeEntry", + "DeckyCreateRequest", + "DeckyRow", + "DeckyUpdateRequest", + "DeployAcceptedResponse", + "EdgeCreateRequest", + "EdgeRow", + "LANCreateRequest", + "LANRow", + "LANUpdateRequest", + "MutationEnqueueRequest", + "MutationEnqueueResponse", + "MutationRow", + "NextIPResponse", + "NextSubnetResponse", + "NotEditableResponse", + "ServiceCatalogResponse", + "Topology", + "TopologyDecky", + "TopologyDetail", + "TopologyEdge", + "TopologyGenerateRequest", + "TopologyListResponse", + "TopologyMutation", + "TopologyStatusEvent", + "TopologyStatusEventRow", + "TopologySummary", + "ValidationErrorResponse", + "ValidationIssueResponse", + "VersionConflictResponse", + # updater + "HostReleaseInfo", + "HostReleasesResponse", + "PushUpdateRequest", + "PushUpdateResponse", + "PushUpdateResult", + "RollbackRequest", + "RollbackResponse", + # workers + "StartAllResponse", + "StartFailure", + "WorkerControlResponse", + "WorkersResponse", + "WorkerStatus", +] diff --git a/decnet/web/db/models/_base.py b/decnet/web/db/models/_base.py new file mode 100644 index 00000000..5db01721 --- /dev/null +++ b/decnet/web/db/models/_base.py @@ -0,0 +1,23 @@ +"""Shared column/validator helpers used across model domain modules.""" +from datetime import datetime +from typing import Annotated, Any, Optional + +from pydantic import BeforeValidator +from sqlalchemy import Text +from sqlalchemy.dialects.mysql import MEDIUMTEXT + +# Use on columns that accumulate over an attacker's lifetime (commands, +# fingerprints, state blobs). TEXT on MySQL caps at 64 KiB; MEDIUMTEXT +# stretches to 16 MiB. SQLite has no fixed-width text types so Text() +# stays unchanged there. +_BIG_TEXT = Text().with_variant(MEDIUMTEXT(), "mysql") + + +def _normalize_null(v: Any) -> Any: + if isinstance(v, str) and v.lower() in ("null", "undefined", ""): + return None + return v + + +NullableDatetime = Annotated[Optional[datetime], BeforeValidator(_normalize_null)] +NullableString = Annotated[Optional[str], BeforeValidator(_normalize_null)] diff --git a/decnet/web/db/models/attackers.py b/decnet/web/db/models/attackers.py new file mode 100644 index 00000000..1a925db9 --- /dev/null +++ b/decnet/web/db/models/attackers.py @@ -0,0 +1,150 @@ +"""Attacker core + per-attacker behavioral and per-session profile rows.""" +from datetime import datetime, timezone +from typing import Any, List, Optional + +from pydantic import BaseModel +from sqlalchemy import Column, Text +from sqlmodel import Field, SQLModel + +from ._base import _BIG_TEXT + + +class Attacker(SQLModel, table=True): + __tablename__ = "attackers" + uuid: str = Field(primary_key=True) + ip: str = Field(index=True) + first_seen: datetime = Field(index=True) + last_seen: datetime = Field(index=True) + event_count: int = Field(default=0) + service_count: int = Field(default=0) + decky_count: int = Field(default=0) + # JSON blobs — these grow over the attacker's lifetime. Use MEDIUMTEXT on + # MySQL (16 MiB) for the fields that accumulate (fingerprints, commands, + # and the deckies/services lists that are unbounded in principle). + services: str = Field( + default="[]", sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]") + ) # JSON list[str] + deckies: str = Field( + default="[]", sa_column=Column("deckies", _BIG_TEXT, nullable=False, default="[]") + ) # JSON list[str], first-contact ordered + traversal_path: Optional[str] = Field( + default=None, sa_column=Column("traversal_path", Text, nullable=True) + ) # "decky-01 → decky-03 → decky-05" + is_traversal: bool = Field(default=False) + bounty_count: int = Field(default=0) + credential_count: int = Field(default=0) + fingerprints: str = Field( + default="[]", sa_column=Column("fingerprints", _BIG_TEXT, nullable=False, default="[]") + ) # JSON list[dict] — bounty fingerprints + commands: str = Field( + default="[]", sa_column=Column("commands", _BIG_TEXT, nullable=False, default="[]") + ) # JSON list[dict] — commands per service/decky + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), index=True + ) + + +class AttackerBehavior(SQLModel, table=True): + """ + Timing & behavioral profile for an attacker, joined to Attacker by uuid. + + Kept in a separate table so the core Attacker row stays narrow and + behavior data can be updated independently (e.g. as the sniffer observes + more packets) without touching the event-count aggregates. + """ + __tablename__ = "attacker_behavior" + attacker_uuid: str = Field(primary_key=True, foreign_key="attackers.uuid") + # OS / TCP stack fingerprint (rolled up from sniffer events) + os_guess: Optional[str] = None + hop_distance: Optional[int] = None + tcp_fingerprint: str = Field( + default="{}", + sa_column=Column("tcp_fingerprint", Text, nullable=False, default="{}"), + ) # JSON: window, wscale, mss, options_sig + # Raw SSH KEX algorithm preference strings observed across HASSH probes + # (one entry per hassh_fingerprint event). Keeping the raw ordered list + # enables post-hoc KEX-order fingerprinting beyond the HASSH hash. + kex_order_raw: Optional[str] = Field( + default=None, + sa_column=Column("kex_order_raw", Text, nullable=True), + ) # JSON list[str] — kex_algorithms comma-separated strings + # Sniffer-observed SSH client identification strings (RFC 4253 §4.2), + # deduped in observation order. Captures the attacker's SSH client + # software (e.g. "SSH-2.0-OpenSSH_9.2p1", "SSH-2.0-libssh2_1.10.0"). + ssh_client_banners: Optional[str] = Field( + default=None, + sa_column=Column("ssh_client_banners", Text, nullable=True), + ) # JSON list[str] + retransmit_count: int = Field(default=0) + # Behavioral (derived by the profiler from log-event timing) + behavior_class: Optional[str] = None # beaconing | interactive | scanning | brute_force | slow_scan | mixed | unknown + beacon_interval_s: Optional[float] = None + beacon_jitter_pct: Optional[float] = None + tool_guesses: Optional[str] = None # JSON list[str] — all matched tools + timing_stats: str = Field( + default="{}", + sa_column=Column("timing_stats", Text, nullable=False, default="{}"), + ) # JSON: mean/median/stdev/min/max IAT + phase_sequence: str = Field( + default="{}", + sa_column=Column("phase_sequence", Text, nullable=False, default="{}"), + ) # JSON: recon_end/exfil_start/latency + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), index=True + ) + + +class SessionProfile(SQLModel, table=True): + """ + Per-session keystroke-dynamics fingerprint. + + One row per recorded interactive session. Pre-v1 the ingestion job + that populates these columns is not yet built (tracked as gap #2 in + SIGNAL_CAPTURE_AUDIT.md); the table ships empty so that: + * downstream correlation/federation work can target a stable schema, and + * `schema_version` is committed to storage from day one — federation + gossip in v2 requires cross-operator compatibility, and retrofitting + a version column after rows exist is painful. + + All feature columns are nullable so the empty write path (one row per + closed session) is valid without the behavioral analyzer online yet. + """ + __tablename__ = "session_profile" + sid: str = Field(primary_key=True) # session UUID + log_id: Optional[int] = Field( + default=None, foreign_key="logs.id", index=True + ) + schema_version: int = Field(default=1) + # Inter-key interval timing moments (seconds). + kd_iki_mean: Optional[float] = None + kd_iki_stdev: Optional[float] = None + kd_iki_p50: Optional[float] = None + kd_iki_p95: Optional[float] = None + kd_enter_latency_p50: Optional[float] = None + kd_enter_latency_p95: Optional[float] = None + # Cadence ratios. + kd_burst_ratio: Optional[float] = None + kd_think_ratio: Optional[float] = None + # Control-character rates (events per keystroke). + kd_ctrl_backspace: Optional[float] = None + kd_ctrl_wkill: Optional[float] = None + kd_ctrl_ukill: Optional[float] = None + kd_ctrl_abort: Optional[float] = None + kd_ctrl_eof: Optional[float] = None + kd_arrow_rate: Optional[float] = None + kd_tab_rate: Optional[float] = None + # 8-byte SimHash over keystroke digraphs — Hamming-comparable across sessions. + kd_digraph_simhash: Optional[bytes] = Field(default=None, index=True) + # Derived totals. + total_keystrokes: Optional[int] = None + session_duration_s: Optional[float] = None + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + + +class AttackersResponse(BaseModel): + total: int + limit: int + offset: int + data: List[dict[str, Any]] diff --git a/decnet/web/db/models/auth.py b/decnet/web/db/models/auth.py new file mode 100644 index 00000000..97a29d76 --- /dev/null +++ b/decnet/web/db/models/auth.py @@ -0,0 +1,73 @@ +"""Auth + user-management tables and DTOs.""" +from typing import List, Literal + +from pydantic import BaseModel, Field as PydanticField +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "users" + uuid: str = Field(primary_key=True) + username: str = Field(index=True, unique=True) + password_hash: str + role: str = Field(default="viewer") + must_change_password: bool = Field(default=False) + + +# --- API Request/Response Models (Pydantic) --- + +class Token(BaseModel): + access_token: str + token_type: str + must_change_password: bool = False + + +class LoginRequest(BaseModel): + username: str + password: str = PydanticField(..., max_length=72) + + +class ChangePasswordRequest(BaseModel): + old_password: str = PydanticField(..., max_length=72) + new_password: str = PydanticField(..., max_length=72) + + +# --- Configuration Models --- + +class CreateUserRequest(BaseModel): + username: str = PydanticField(..., min_length=1, max_length=64) + password: str = PydanticField(..., min_length=8, max_length=72) + role: Literal["admin", "viewer"] = "viewer" + + +class UpdateUserRoleRequest(BaseModel): + role: Literal["admin", "viewer"] + + +class ResetUserPasswordRequest(BaseModel): + new_password: str = PydanticField(..., min_length=8, max_length=72) + + +class DeploymentLimitRequest(BaseModel): + deployment_limit: int = PydanticField(..., ge=1, le=500) + + +class GlobalMutationIntervalRequest(BaseModel): + global_mutation_interval: str = PydanticField(..., pattern=r"^[1-9]\d*[mdMyY]$") + + +class UserResponse(BaseModel): + uuid: str + username: str + role: str + must_change_password: bool + + +class ConfigResponse(BaseModel): + role: str + deployment_limit: int + global_mutation_interval: str + + +class AdminConfigResponse(ConfigResponse): + users: List[UserResponse] diff --git a/decnet/web/db/models/deploy.py b/decnet/web/db/models/deploy.py new file mode 100644 index 00000000..a0990e48 --- /dev/null +++ b/decnet/web/db/models/deploy.py @@ -0,0 +1,19 @@ +"""Fleet deploy + mutate-interval request DTOs.""" +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field as PydanticField + +from decnet.models import IniContent + + +class MutateIntervalRequest(BaseModel): + # Human-readable duration: where unit is m(inutes), d(ays), M(onths), y/Y(ears). + # Minimum granularity is 1 minute. Seconds are not accepted. + mutate_interval: Optional[str] = PydanticField(None, pattern=r"^[1-9]\d*[mdMyY]$") + + +class DeployIniRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + # This field now enforces strict INI structure during Pydantic initialization. + # The OpenAPI schema correctly shows it as a required string. + ini_content: IniContent = PydanticField(..., description="A valid INI formatted string") diff --git a/decnet/web/db/models/health.py b/decnet/web/db/models/health.py new file mode 100644 index 00000000..1c54af54 --- /dev/null +++ b/decnet/web/db/models/health.py @@ -0,0 +1,14 @@ +"""Health-endpoint DTOs.""" +from typing import Literal, Optional + +from pydantic import BaseModel + + +class ComponentHealth(BaseModel): + status: Literal["ok", "failing"] + detail: Optional[str] = None + + +class HealthResponse(BaseModel): + status: Literal["healthy", "degraded", "unhealthy"] + components: dict[str, ComponentHealth] diff --git a/decnet/web/db/models/logs.py b/decnet/web/db/models/logs.py new file mode 100644 index 00000000..0b5a6d8d --- /dev/null +++ b/decnet/web/db/models/logs.py @@ -0,0 +1,69 @@ +"""Log / Bounty / State tables + their list-response DTOs.""" +from datetime import datetime, timezone +from typing import Any, List, Optional + +from pydantic import BaseModel +from sqlalchemy import Column, Text +from sqlmodel import Field, SQLModel + +from ._base import _BIG_TEXT + + +class Log(SQLModel, table=True): + __tablename__ = "logs" + id: Optional[int] = Field(default=None, primary_key=True) + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) + decky: str = Field(index=True) + service: str = Field(index=True) + event_type: str = Field(index=True) + attacker_ip: str = Field(index=True) + # Long-text columns — use TEXT so MySQL DDL doesn't truncate to VARCHAR(255). + # TEXT is equivalent to plain text in SQLite. + raw_line: str = Field(sa_column=Column("raw_line", Text, nullable=False)) + fields: str = Field(sa_column=Column("fields", Text, nullable=False)) + msg: Optional[str] = Field(default=None, sa_column=Column("msg", Text, nullable=True)) + # OTEL trace context — bridges the collector→ingester trace to the SSE + # read path. Nullable so pre-existing rows and non-traced deployments + # are unaffected. + trace_id: Optional[str] = Field(default=None) + span_id: Optional[str] = Field(default=None) + + +class Bounty(SQLModel, table=True): + __tablename__ = "bounty" + id: Optional[int] = Field(default=None, primary_key=True) + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) + decky: str = Field(index=True) + service: str = Field(index=True) + attacker_ip: str = Field(index=True) + bounty_type: str = Field(index=True) + payload: str = Field(sa_column=Column("payload", Text, nullable=False)) + + +class State(SQLModel, table=True): + __tablename__ = "state" + key: str = Field(primary_key=True) + # JSON-serialized DecnetConfig or other state blobs — can be large as + # deckies/services accumulate. MEDIUMTEXT on MySQL (16 MiB ceiling). + value: str = Field(sa_column=Column("value", _BIG_TEXT, nullable=False)) + + +class LogsResponse(BaseModel): + total: int + limit: int + offset: int + data: List[dict[str, Any]] + + +class BountyResponse(BaseModel): + total: int + limit: int + offset: int + data: List[dict[str, Any]] + + +class StatsResponse(BaseModel): + total_logs: int + unique_attackers: int + active_deckies: int + deployed_deckies: int diff --git a/decnet/web/db/models/swarm.py b/decnet/web/db/models/swarm.py new file mode 100644 index 00000000..68785952 --- /dev/null +++ b/decnet/web/db/models/swarm.py @@ -0,0 +1,200 @@ +"""Swarm host + decky shard tables and their HTTP DTOs.""" +from datetime import datetime, timezone +from typing import Annotated, Any, Optional + +from pydantic import BaseModel, Field as PydanticField +from sqlalchemy import Column, Text +from sqlmodel import Field, SQLModel + +from decnet.models import DecnetConfig + +from ._base import _BIG_TEXT + + +class SwarmHost(SQLModel, table=True): + """A worker host enrolled into a DECNET swarm. + + Rows exist only on the master. Populated by `decnet swarm enroll` and + read by the swarm controller when sharding deckies onto workers. + """ + __tablename__ = "swarm_hosts" + uuid: str = Field(primary_key=True) + name: str = Field(index=True, unique=True) + address: str # IP or hostname reachable by the master + agent_port: int = Field(default=8765) + status: str = Field(default="enrolled", index=True) + # ISO-8601 string of the last successful agent /health probe + last_heartbeat: Optional[datetime] = Field(default=None) + client_cert_fingerprint: str # SHA-256 hex of worker's issued client cert + # SHA-256 hex of the updater-identity cert, if the host was enrolled + # with ``--updater`` / ``issue_updater_bundle``. ``None`` for hosts + # that only have an agent identity. + updater_cert_fingerprint: Optional[str] = Field(default=None) + # Directory on the master where the per-worker cert bundle lives + cert_bundle_path: str + enrolled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + notes: Optional[str] = Field(default=None, sa_column=Column("notes", Text, nullable=True)) + # Per-host driver preference. True => deckies on this host run over IPvlan + # (L2) instead of macvlan — required when the host is a VirtualBox guest + # bridged over Wi-Fi, because Wi-Fi APs only allow one MAC per station + # and macvlan's per-container MACs rotate the VM's DHCP lease. + use_ipvlan: bool = Field(default=False) + + +class DeckyShard(SQLModel, table=True): + """Mapping of a single decky to the worker host running it (swarm mode).""" + __tablename__ = "decky_shards" + decky_name: str = Field(primary_key=True) + host_uuid: str = Field(foreign_key="swarm_hosts.uuid", index=True) + # JSON list of service names running on this decky (snapshot of assignment). + services: str = Field(sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]")) + # Full serialised DeckyConfig from the most recent dispatch or heartbeat. + # Lets the dashboard render the same rich card (hostname/distro/archetype/ + # service_config/mutate_interval) that the local-fleet view uses, without + # needing a live round-trip to the worker for every page render. + decky_config: Optional[str] = Field( + default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) + ) + decky_ip: Optional[str] = Field(default=None) + state: str = Field(default="pending", index=True) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed + last_error: Optional[str] = Field(default=None, sa_column=Column("last_error", Text, nullable=True)) + compose_hash: Optional[str] = Field(default=None) + # Timestamp of the last heartbeat that echoed this shard; lets the UI + # show "stale" decks whose agent has gone silent. + last_seen: Optional[datetime] = Field(default=None) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# --- Swarm API DTOs --- +# Request/response contracts for the master-side swarm controller +# (decnet/web/swarm_api.py). The underlying SQLModel tables — SwarmHost and +# DeckyShard — live above; these are the HTTP-facing shapes. + +class SwarmEnrollRequest(BaseModel): + # x509 CommonName is capped at 64 bytes (RFC 5280 UB-common-name) — the + # cert issuer would reject anything longer with a ValueError. + # Pattern: ASCII hostname-safe characters only. The name is embedded + # both in the CN and as a SAN DNS entry; x509.DNSName only accepts + # A-label ASCII, so non-ASCII would blow up at issuance. + name: str = PydanticField( + ..., min_length=1, max_length=64, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._\-]*$", + ) + address: str = PydanticField( + ..., min_length=1, max_length=253, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:\-]*$", + description="IP or DNS the master uses to reach the worker", + ) + agent_port: int = PydanticField(default=8765, ge=1, le=65535) + sans: list[ + Annotated[ + str, + PydanticField( + min_length=1, max_length=253, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:\-]*$", + ), + ] + ] = PydanticField( + default_factory=list, + description="Extra SANs (IPs / hostnames) to embed in the worker cert", + ) + notes: Optional[str] = None + issue_updater_bundle: bool = PydanticField( + default=False, + description="If true, also issue an updater cert (CN=updater@) for the remote self-updater", + ) + + +class SwarmUpdaterBundle(BaseModel): + """Subset of SwarmEnrolledBundle for the updater identity.""" + fingerprint: str + updater_cert_pem: str + updater_key_pem: str + + +class SwarmEnrolledBundle(BaseModel): + """Cert bundle returned to the operator — must be delivered to the worker.""" + host_uuid: str + name: str + address: str + agent_port: int + fingerprint: str + ca_cert_pem: str + worker_cert_pem: str + worker_key_pem: str + updater: Optional[SwarmUpdaterBundle] = None + + +class SwarmHostView(BaseModel): + uuid: str + name: str + address: str + agent_port: int + status: str + last_heartbeat: Optional[datetime] = None + client_cert_fingerprint: str + updater_cert_fingerprint: Optional[str] = None + enrolled_at: datetime + notes: Optional[str] = None + use_ipvlan: bool = False + + +class DeckyShardView(BaseModel): + """One decky → host mapping, enriched with the host's identity for display.""" + decky_name: str + decky_ip: Optional[str] = None # resolved from the stored DecnetConfig at read time + host_uuid: str + host_name: str + host_address: str + host_status: str + services: list[str] + state: str + last_error: Optional[str] = None + compose_hash: Optional[str] = None + updated_at: datetime + # Enriched fields lifted from the stored DeckyConfig snapshot so the + # dashboard can render the same card shape as the local-fleet view. + hostname: Optional[str] = None + distro: Optional[str] = None + archetype: Optional[str] = None + service_config: dict[str, dict[str, Any]] = {} + mutate_interval: Optional[int] = None + last_mutated: float = 0.0 + last_seen: Optional[datetime] = None + + +class SwarmDeployRequest(BaseModel): + config: DecnetConfig + dry_run: bool = False + no_cache: bool = False + + +class SwarmTeardownRequest(BaseModel): + host_uuid: Optional[str] = PydanticField( + default=None, + description="If set, tear down only this worker; otherwise tear down all hosts", + ) + decky_id: Optional[str] = None + + +class SwarmHostResult(BaseModel): + host_uuid: str + host_name: str + ok: bool + detail: Any | None = None + + +class SwarmDeployResponse(BaseModel): + results: list[SwarmHostResult] + + +class SwarmHostHealth(BaseModel): + host_uuid: str + name: str + address: str + reachable: bool + detail: Any | None = None + + +class SwarmCheckResponse(BaseModel): + results: list[SwarmHostHealth] diff --git a/decnet/web/db/models/topology.py b/decnet/web/db/models/topology.py new file mode 100644 index 00000000..29b9ac8b --- /dev/null +++ b/decnet/web/db/models/topology.py @@ -0,0 +1,422 @@ +"""MazeNET topology tables + the REST DTOs that wrap them.""" +from datetime import datetime, timezone +from typing import Annotated, Any, Literal, Optional +from uuid import uuid4 + +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field as PydanticField +from sqlalchemy import Column, Index, Text, UniqueConstraint +from sqlmodel import Field, SQLModel + +from ._base import _BIG_TEXT + + +# --- MazeNET tables --- +# Nested deception topologies: an arbitrary-depth DAG of LANs connected by +# multi-homed "bridge" deckies. Purpose-built; disjoint from DeckyShard which +# remains SWARM-only. + +class Topology(SQLModel, table=True): + __tablename__ = "topologies" + id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + name: str = Field(index=True, unique=True) + mode: str = Field(default="unihost") # unihost|agent + # When ``mode == "agent"``, pins this topology to a specific enrolled + # worker. ``None`` for unihost topologies (master-local deploy). + target_host_uuid: Optional[str] = Field( + default=None, foreign_key="swarm_hosts.uuid", index=True + ) + # Full TopologyConfig snapshot (including seed) used at generation time. + config_snapshot: str = Field( + sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}") + ) + status: str = Field( + default="pending", index=True + ) # pending|deploying|active|degraded|failed|tearing_down|torn_down + status_changed_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), index=True + ) + # Optimistic-concurrency token. Bumped by repo methods that mutate + # the topology or any child row when an expected_version is supplied. + # Callers pass their last-seen version; mismatch raises VersionConflict. + version: int = Field(default=1, nullable=False) + # Set by the heartbeat handler when an agent's reported + # ``applied_version_hash`` diverges from what we expect it to be + # running. Drained by the mutator watch loop, which re-pushes via + # AgentClient and clears the flag. NULL for unihost topologies. + needs_resync: bool = Field(default=False, nullable=False) + + +class LAN(SQLModel, table=True): + __tablename__ = "lans" + __table_args__ = (UniqueConstraint("topology_id", "name", name="uq_lan_topology_name"),) + id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + topology_id: str = Field(foreign_key="topologies.id", index=True) + name: str + # Populated after the Docker network is created; nullable before deploy. + docker_network_id: Optional[str] = Field(default=None) + subnet: str + is_dmz: bool = Field(default=False) + # Canvas layout coordinates (set by the web editor). Nullable so + # generator-emitted LANs don't need auto-layout at generation time. + x: Optional[float] = Field(default=None) + y: Optional[float] = Field(default=None) + + +class TopologyDecky(SQLModel, table=True): + """A decky belonging to a MazeNET topology. + + Disjoint from DeckyShard (which is SWARM-only). UUID PK; decky name is + unique only within a topology, so two topologies can both have a + ``decky-01`` without colliding. + """ + __tablename__ = "topology_deckies" + __table_args__ = ( + UniqueConstraint("topology_id", "name", name="uq_topology_decky_name"), + ) + uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + topology_id: str = Field(foreign_key="topologies.id", index=True) + name: str + # JSON list[str] of service names on this decky (snapshot of assignment). + services: str = Field( + sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]") + ) + # Full serialised DeckyConfig snapshot — lets the dashboard render the + # same card shape as DeckyShard without a live round-trip. + decky_config: Optional[str] = Field( + default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) + ) + ip: Optional[str] = Field(default=None) + # Same vocabulary as DeckyShard.state to keep dashboard rendering uniform. + state: str = Field( + default="pending", index=True + ) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed + last_error: Optional[str] = Field( + default=None, sa_column=Column("last_error", Text, nullable=True) + ) + compose_hash: Optional[str] = Field(default=None) + last_seen: Optional[datetime] = Field(default=None) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + # Canvas layout coordinates (set by the web editor). Nullable so + # generator-emitted deckies don't need auto-layout at generation time. + x: Optional[float] = Field(default=None) + y: Optional[float] = Field(default=None) + + +class TopologyEdge(SQLModel, table=True): + """Membership edge: a decky attached to a LAN. + + A decky appearing in ≥2 edges is multi-homed (a bridge decky). + """ + __tablename__ = "topology_edges" + id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + topology_id: str = Field(foreign_key="topologies.id", index=True) + decky_uuid: str = Field(foreign_key="topology_deckies.uuid", index=True) + lan_id: str = Field(foreign_key="lans.id", index=True) + is_bridge: bool = Field(default=False) + forwards_l3: bool = Field(default=False) + + +class TopologyStatusEvent(SQLModel, table=True): + """Append-only audit log of topology status transitions.""" + __tablename__ = "topology_status_events" + id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + topology_id: str = Field(foreign_key="topologies.id", index=True) + from_status: str + to_status: str + at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), index=True + ) + reason: Optional[str] = Field( + default=None, sa_column=Column("reason", Text, nullable=True) + ) + + +class TopologyMutation(SQLModel, table=True): + """Operator-requested live mutation for an active MazeNET topology. + + Each row is one intent (add LAN, attach decky, etc.). The mutator's + reconciler claims ``pending`` rows atomically (see + ``SQLModelRepository.claim_next_mutation``), applies them against + Docker, and writes ``applied`` or ``failed`` back. The ``(state, + topology_id)`` composite index keeps the watch-loop guard query + cheap even with years of mutation history. + """ + __tablename__ = "topology_mutations" + __table_args__ = ( + Index( + "ix_topology_mutations_state_topology", + "state", + "topology_id", + ), + ) + id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) + topology_id: str = Field(foreign_key="topologies.id", index=True) + # add_lan|remove_lan|add_decky|attach_decky|detach_decky| + # remove_decky|update_decky|update_lan + op: str = Field(index=True) + # JSON-serialised op payload (keys depend on ``op``). + payload: str = Field( + sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}") + ) + # pending|applying|applied|failed + state: str = Field(default="pending", index=True) + requested_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), index=True + ) + applied_at: Optional[datetime] = Field(default=None) + reason: Optional[str] = Field( + default=None, sa_column=Column("reason", Text, nullable=True) + ) + + +# --- MazeNET Topology REST DTOs (phase 3) --- +# Request/response shapes for /api/v1/topologies. All write paths are +# admin-only; reads accept admin or viewer. Child CRUD is pending-only; +# mutations of active|degraded topologies go through the queue. + + +class TopologyGenerateRequest(BaseModel): + """Body for POST /topologies — mirrors the `topology generate` CLI.""" + name: str = PydanticField(..., min_length=1, max_length=64) + mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$") + target_host_uuid: Optional[str] = None + depth: int = PydanticField(..., ge=1, le=16) + branching_factor: int = PydanticField(..., ge=1, le=8) + deckies_per_lan_min: int = PydanticField(default=1, ge=0, le=32) + deckies_per_lan_max: int = PydanticField(default=3, ge=1, le=32) + bridge_forward_probability: float = PydanticField(default=1.0, ge=0.0, le=1.0) + cross_edge_probability: float = PydanticField(default=0.0, ge=0.0, le=1.0) + services_explicit: Optional[list[str]] = None + randomize_services: bool = True + seed: Optional[int] = PydanticField(default=None, ge=0) + + +class TopologySummary(BaseModel): + """List-row shape for GET /topologies.""" + model_config = ConfigDict(extra="ignore") + id: str + name: str + mode: str + target_host_uuid: Optional[str] = None + status: str + version: int + needs_resync: bool = False + created_at: datetime + status_changed_at: Optional[datetime] = None + + +class TopologyListResponse(BaseModel): + total: int + limit: Optional[int] = None + offset: Optional[int] = None + data: list[TopologySummary] + + +class LANRow(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str + topology_id: str + name: str + subnet: str + is_dmz: bool = False + docker_network_id: Optional[str] = None + x: Optional[float] = None + y: Optional[float] = None + + +class DeckyRow(BaseModel): + model_config = ConfigDict(extra="ignore") + uuid: str + topology_id: str + name: str + services: list[str] = PydanticField(default_factory=list) + decky_config: Optional[dict[str, Any]] = None + ip: Optional[str] = None + state: str + last_error: Optional[str] = None + x: Optional[float] = None + y: Optional[float] = None + + +class EdgeRow(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str + topology_id: str + decky_uuid: str + lan_id: str + is_bridge: bool = False + forwards_l3: bool = False + + +class TopologyDetail(BaseModel): + """Hydrated topology — mirrors persistence.hydrate() output. + + ``topology`` uses :class:`TopologySummary` which already exposes + ``target_host_uuid`` — agent-targeted topologies surface their + pinned host through that field. + """ + topology: TopologySummary + lans: list[LANRow] + deckies: list[DeckyRow] + edges: list[EdgeRow] + + +class TopologyStatusEventRow(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str + topology_id: str + from_status: str + to_status: str + at: datetime + reason: Optional[str] = None + + +class LANCreateRequest(BaseModel): + name: str = PydanticField(..., min_length=1, max_length=64) + subnet: Optional[str] = None + is_dmz: bool = False + x: Optional[float] = None + y: Optional[float] = None + expected_version: Optional[int] = None + + +class LANUpdateRequest(BaseModel): + name: Optional[str] = None + subnet: Optional[str] = None + is_dmz: Optional[bool] = None + x: Optional[float] = None + y: Optional[float] = None + expected_version: Optional[int] = None + + +class DeckyCreateRequest(BaseModel): + name: str = PydanticField(..., min_length=1, max_length=64) + services: list[str] = PydanticField(default_factory=list) + decky_config: Optional[dict[str, Any]] = None + x: Optional[float] = None + y: Optional[float] = None + expected_version: Optional[int] = None + + +class DeckyUpdateRequest(BaseModel): + name: Optional[str] = None + services: Optional[list[str]] = None + decky_config: Optional[dict[str, Any]] = None + x: Optional[float] = None + y: Optional[float] = None + expected_version: Optional[int] = None + + +class EdgeCreateRequest(BaseModel): + decky_uuid: str + lan_id: str + is_bridge: bool = False + forwards_l3: bool = False + expected_version: Optional[int] = None + + +_MUTATION_OPS = Literal[ + "add_lan", + "remove_lan", + "add_decky", + "attach_decky", + "detach_decky", + "remove_decky", + "update_decky", + "update_lan", +] + + +class MutationEnqueueRequest(BaseModel): + op: _MUTATION_OPS + payload: dict[str, Any] = PydanticField(default_factory=dict) + expected_version: Optional[int] = None + + +def _decode_json_payload(v: Any) -> Any: + """Accept either a dict or a JSON-encoded string for mutation payloads.""" + if isinstance(v, str): + import json as _json + return _json.loads(v) if v else {} + return v + + +_MutationPayload = Annotated[dict[str, Any], BeforeValidator(_decode_json_payload)] + + +class MutationRow(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str + topology_id: str + op: str + payload: _MutationPayload = PydanticField(default_factory=dict) + state: str + requested_at: datetime + applied_at: Optional[datetime] = None + reason: Optional[str] = None + + +class MutationEnqueueResponse(BaseModel): + mutation_id: str + state: str = "pending" + + +class ValidationIssueResponse(BaseModel): + severity: str + code: str + message: str + target: dict[str, Any] = PydanticField(default_factory=dict) + + +class ValidationErrorResponse(BaseModel): + detail: str = "Topology validation failed" + issues: list[ValidationIssueResponse] + + +class VersionConflictResponse(BaseModel): + detail: str = "Topology version conflict" + current: int + expected: int + + +class NotEditableResponse(BaseModel): + detail: str = "Topology not editable" + status: str + reason: Optional[str] = None + + +class ServiceCatalogResponse(BaseModel): + services: list[str] + + +class ArchetypeEntry(BaseModel): + slug: str + display_name: str + description: str + services: list[str] + preferred_distros: list[str] + nmap_os: str + + +class ArchetypeCatalogResponse(BaseModel): + archetypes: list[ArchetypeEntry] + + +class NextIPResponse(BaseModel): + subnet: str + ip: str + + +class NextSubnetResponse(BaseModel): + subnet: str + + +class DeployAcceptedResponse(BaseModel): + topology_id: str + status: str + dry_run: bool = False diff --git a/decnet/web/db/models/updater.py b/decnet/web/db/models/updater.py new file mode 100644 index 00000000..07f0dc8d --- /dev/null +++ b/decnet/web/db/models/updater.py @@ -0,0 +1,73 @@ +"""Remote updates DTOs (master → worker /updater fan-out).""" +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field as PydanticField + + +# --- Remote Updates (master → worker /updater) DTOs --- +# Powers the dashboard's Remote Updates page. The master dashboard calls +# these (auth-gated) endpoints; internally they fan out to each worker's +# updater daemon over mTLS via UpdaterClient. + +class HostReleaseInfo(BaseModel): + host_uuid: str + host_name: str + address: str + reachable: bool + # These fields mirror the updater's /health payload when reachable; they + # are all Optional so an unreachable host still serializes cleanly. + agent_status: Optional[str] = None + current_sha: Optional[str] = None + previous_sha: Optional[str] = None + releases: list[dict[str, Any]] = PydanticField(default_factory=list) + detail: Optional[str] = None # populated when unreachable + + +class HostReleasesResponse(BaseModel): + hosts: list[HostReleaseInfo] + + +class PushUpdateRequest(BaseModel): + host_uuids: Optional[list[str]] = PydanticField( + default=None, + description="Target specific hosts; mutually exclusive with 'all'.", + ) + all: bool = PydanticField(default=False, description="Target every non-decommissioned host with an updater bundle.") + include_self: bool = PydanticField( + default=False, + description="After a successful /update, also push /update-self to upgrade the updater itself.", + ) + exclude: list[str] = PydanticField( + default_factory=list, + description="Additional tarball exclude globs (on top of the built-in defaults).", + ) + + +class PushUpdateResult(BaseModel): + host_uuid: str + host_name: str + # updated = /update 200. rolled-back = /update 409 (auto-recovered). + # failed = transport error or non-200/409 response. self-updated = /update-self succeeded. + status: Literal["updated", "rolled-back", "failed", "self-updated", "self-failed"] + http_status: Optional[int] = None + sha: Optional[str] = None + detail: Optional[str] = None + stderr: Optional[str] = None + + +class PushUpdateResponse(BaseModel): + sha: str + tarball_bytes: int + results: list[PushUpdateResult] + + +class RollbackRequest(BaseModel): + host_uuid: str = PydanticField(..., description="Host to roll back to its previous release slot.") + + +class RollbackResponse(BaseModel): + host_uuid: str + host_name: str + status: Literal["rolled-back", "failed"] + http_status: Optional[int] = None + detail: Optional[str] = None diff --git a/decnet/web/db/models/workers.py b/decnet/web/db/models/workers.py new file mode 100644 index 00000000..3bc14c11 --- /dev/null +++ b/decnet/web/db/models/workers.py @@ -0,0 +1,50 @@ +"""Workers panel DTOs (bus-backed health + control).""" +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field as PydanticField + + +# --- Workers panel (Config → Workers) --- +# Bus-backed health + control: workers heartbeat on ``system..health`` +# and listen on ``system..control``. The API aggregates last-seen +# heartbeats via the worker registry; these are the HTTP-facing shapes. + +class WorkerStatus(BaseModel): + name: str + # ``ok`` — heartbeat within 90s (3× 30s heartbeat interval) + # ``stale`` — worker was seen before but hasn't pulsed in 90s+ + # ``unknown`` — we've never received a heartbeat from this name + status: Literal["ok", "stale", "unknown"] + last_heartbeat_ts: Optional[float] = None + seconds_since: Optional[float] = None + # Whatever the worker's ``extra()`` callback put in the heartbeat; + # opaque to the panel, displayed only if the UI knows the key. + extra: Dict[str, Any] = PydanticField(default_factory=dict) + # True iff a ``decnet-.service`` unit file is present on the + # host. False flips the UI START button to disabled with a + # "Unit not installed" tooltip. Default True for backwards compat + # on clients that pre-date the field. + installed: bool = True + + +class WorkersResponse(BaseModel): + workers: List[WorkerStatus] + generated_at: float + bus_connected: bool + + +class WorkerControlResponse(BaseModel): + accepted: bool + worker: str + action: str + + +class StartFailure(BaseModel): + name: str + reason: str + + +class StartAllResponse(BaseModel): + started: List[str] + already_running: List[str] + failed: List[StartFailure]