feat(orchestrator): MVP synthetic life-injection worker (SSH only)

Adds a new decnet orchestrate worker whose job is to keep the honeypot
ecosystem from looking suspiciously static — a frozen LAN with no
inter-host traffic and no filesystem aging is its own honeypot tell.

MVP scope:
- New OrchestratorEvent table + repo methods (purpose-built sibling
  to Log so synthetic events stay separable from attacker-driven ones).
- New orchestrator.{activity,file}.<decky_id> bus topics +
  system.orchestrator.health heartbeat.
- SSH-only driver. Traffic action runs python3 inside src container
  to TCP-connect dst:22 and read the SSH banner — real on-the-wire
  SSH-protocol traffic without shipping creds. File action drops or
  refreshes a small file via docker exec on the destination.
- Random scheduler (50/50 traffic/file when >=2 SSH-capable deckies
  are running). Diurnal shaping, role-aware pairing, and session-aware
  backoff are explicit non-goals for MVP.
- CLI registration, systemd unit (SupplementaryGroups=docker),
  worker-registry entry so the dashboard shows orchestrator health.
- 11 tests: scheduler policy, driver argv shape + injection-safety,
  end-to-end one-tick integration with FakeBus + SQLite.
This commit is contained in:
2026-04-26 19:43:20 -04:00
parent cc2deb73f7
commit 4c37ece39e
21 changed files with 972 additions and 1 deletions

View File

@@ -53,6 +53,9 @@ from .health import (
ComponentHealth,
HealthResponse,
)
from .orchestrator import (
OrchestratorEvent,
)
from .logs import (
Bounty,
BountyResponse,
@@ -181,6 +184,8 @@ __all__ = [
# health
"ComponentHealth",
"HealthResponse",
# orchestrator
"OrchestratorEvent",
# logs
"Bounty",
"BountyResponse",

View File

@@ -0,0 +1,52 @@
"""Orchestrator-emitted activity events.
Purpose-built sibling to ``logs.Log`` so attacker-originated events stay
cleanly separable from synthetic life-injection events at query time.
The orchestrator worker is the sole writer.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from sqlalchemy import Column, Index, Text
from sqlmodel import Field, SQLModel
class OrchestratorEvent(SQLModel, table=True):
"""One orchestrator-driven action against a decky.
``kind`` discriminates the two MVP flavours:
* ``"traffic"`` — a protocol-driven interaction (SSH command exec for
MVP). ``src_decky_uuid`` is the *logical* originator and may differ
from the actual TCP source for the duration of the MVP, where the
orchestrator process drives the connection from the host. ``v1``
will execute the connection from inside the source container.
* ``"file"`` — a filesystem touch via ``docker exec`` against the
destination decky. ``src_decky_uuid`` is null.
``payload`` is the per-action JSON envelope: command run, exit code,
stdout/stderr digest, file path, byte counts, etc. Schema is
deliberately loose — the worker can extend it without a migration.
"""
__tablename__ = "orchestrator_events"
__table_args__ = (
Index("ix_orchestrator_events_dst_ts", "dst_decky_uuid", "ts"),
)
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
ts: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), index=True
)
kind: str = Field(index=True, max_length=16) # traffic|file
protocol: str = Field(index=True, max_length=16) # ssh for MVP
action: str = Field(max_length=64) # exec:uptime|file:create|...
src_decky_uuid: Optional[str] = Field(
default=None, foreign_key="topology_deckies.uuid", index=True
)
dst_decky_uuid: str = Field(
foreign_key="topology_deckies.uuid", index=True
)
success: bool = Field(default=False, index=True)
payload: str = Field(
sa_column=Column("payload", Text, nullable=False, default="{}")
)

View File

@@ -857,3 +857,24 @@ class BaseRepository(ABC):
"""Auto-disable a subscription after repeated failures. Sets
``enabled=False`` and stamps ``auto_disabled_at``."""
raise NotImplementedError
# ---------------------------------------------------------- orchestrator
async def list_running_topology_deckies(self) -> list[dict[str, Any]]:
"""Return every TopologyDecky row whose ``state == 'running'``.
The orchestrator picks pairs from this set to drive synthetic
inter-decky activity. Cross-topology by design: a multi-topology
host still has a single orchestrator instance.
"""
raise NotImplementedError
async def record_orchestrator_event(self, data: dict[str, Any]) -> str:
"""Insert one orchestrator-emitted event row, returning its uuid."""
raise NotImplementedError
async def list_orchestrator_events(
self, *, limit: int = 100, since_ts: Optional[Any] = None
) -> list[dict[str, Any]]:
"""Return recent orchestrator events newest-first."""
raise NotImplementedError

View File

@@ -49,6 +49,7 @@ from decnet.web.db.models import (
TopologyEdge,
TopologyStatusEvent,
TopologyMutation,
OrchestratorEvent,
WebhookSubscription,
)
@@ -2787,3 +2788,42 @@ class SQLModelRepository(BaseRepository):
)
)
await session.commit()
# ---------------------------------------------------------- orchestrator
async def list_running_topology_deckies(self) -> list[dict[str, Any]]:
async with self._session() as session:
result = await session.execute(
select(TopologyDecky).where(TopologyDecky.state == "running")
)
return [
self._deserialize_json_fields(
r.model_dump(mode="json"), ("services", "decky_config")
)
for r in result.scalars().all()
]
async def record_orchestrator_event(self, data: dict[str, Any]) -> str:
payload = data.get("payload")
if isinstance(payload, (dict, list)):
data = {**data, "payload": json.dumps(payload)}
async with self._session() as session:
row = OrchestratorEvent(**data)
session.add(row)
await session.commit()
await session.refresh(row)
return row.uuid
async def list_orchestrator_events(
self,
*,
limit: int = 100,
since_ts: Optional[datetime] = None,
) -> list[dict[str, Any]]:
async with self._session() as session:
stmt = select(OrchestratorEvent)
if since_ts is not None:
stmt = stmt.where(OrchestratorEvent.ts >= since_ts)
stmt = stmt.order_by(desc(OrchestratorEvent.ts)).limit(limit)
result = await session.execute(stmt)
return [r.model_dump(mode="json") for r in result.scalars().all()]

View File

@@ -28,6 +28,7 @@ _PREFERRED_ORDER: tuple[str, ...] = (
"reuse-correlator",
"enrich",
"webhook",
"orchestrator",
)

View File

@@ -41,6 +41,7 @@ KNOWN_WORKERS: tuple[str, ...] = (
"reuse-correlator", # credential-reuse pass — bus-woken on credential.captured
"enrich", # threat-intel enrichment — bus-woken on attacker.observed/scored
"webhook", # external SIEM/SOAR egress — bus consumer → HMAC HTTP POSTs
"orchestrator", # synthetic life-injection — inter-decky traffic + file ops
"agent",
"forwarder",
"updater",