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:
@@ -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",
|
||||
|
||||
52
decnet/web/db/models/orchestrator.py
Normal file
52
decnet/web/db/models/orchestrator.py
Normal 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="{}")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -28,6 +28,7 @@ _PREFERRED_ORDER: tuple[str, ...] = (
|
||||
"reuse-correlator",
|
||||
"enrich",
|
||||
"webhook",
|
||||
"orchestrator",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user