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:
97
decnet/orchestrator/scheduler.py
Normal file
97
decnet/orchestrator/scheduler.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Action picker for the orchestrator.
|
||||
|
||||
MVP policy: flat random — pick one (src, dst) pair where both deckies
|
||||
expose SSH, then choose one of {ssh-traffic, file-touch}. No diurnal
|
||||
shaping, no role-aware pairing — those land in v1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
# A small set of plausible filenames the orchestrator drops or refreshes.
|
||||
# Scope on purpose: the file driver is "prove the docker-exec write path
|
||||
# works", not "generate believable user activity". Realism is v2.
|
||||
# Paths target the filesystem *inside* a decoy container, not the host.
|
||||
# Bandit B108 is a host-side concern; suppressed at the data definition.
|
||||
_FILE_TEMPLATES: tuple[tuple[str, str], ...] = ( # nosec B108
|
||||
("/tmp/.cache-{ts}.tmp", "session={ts}\n"), # nosec B108
|
||||
("/var/log/cron-{ts}.log", "{ts} CRON[{n}]: ({user}) CMD (run-parts /etc/cron.daily)\n"),
|
||||
("/home/{user}/notes-{ts}.txt", "todo: rotate keys; check on backup task\n"),
|
||||
)
|
||||
|
||||
_USERS = ("admin", "ubuntu", "service")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAction:
|
||||
src_uuid: str
|
||||
src_name: str
|
||||
dst_uuid: str
|
||||
dst_name: str
|
||||
dst_ip: str
|
||||
protocol: str = "ssh"
|
||||
description: str = "tcp_connect:22"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileAction:
|
||||
dst_uuid: str
|
||||
dst_name: str
|
||||
path: str
|
||||
content: str
|
||||
description: str = "file:create"
|
||||
|
||||
|
||||
Action = TrafficAction | FileAction
|
||||
|
||||
|
||||
def _has_ssh(decky: dict[str, Any]) -> bool:
|
||||
services = decky.get("services") or []
|
||||
if isinstance(services, str):
|
||||
return False # not deserialised — treat as "we don't know"
|
||||
return "ssh" in services
|
||||
|
||||
|
||||
def pick(
|
||||
deckies: Sequence[dict[str, Any]],
|
||||
*,
|
||||
rand: Optional[secrets.SystemRandom] = None,
|
||||
) -> Optional[Action]:
|
||||
"""Pick one action against the given decky set.
|
||||
|
||||
Returns ``None`` when no action is possible (fewer than two SSH-capable
|
||||
deckies for traffic, or no deckies at all for file ops). The worker
|
||||
treats ``None`` as "skip this tick".
|
||||
"""
|
||||
rng = rand or secrets.SystemRandom()
|
||||
ssh_deckies = [d for d in deckies if _has_ssh(d) and d.get("ip")]
|
||||
if not ssh_deckies:
|
||||
return None
|
||||
|
||||
kind = "traffic" if (len(ssh_deckies) >= 2 and rng.random() < 0.5) else "file"
|
||||
|
||||
if kind == "traffic":
|
||||
src, dst = rng.sample(ssh_deckies, 2)
|
||||
return TrafficAction(
|
||||
src_uuid=src["uuid"],
|
||||
src_name=src["name"],
|
||||
dst_uuid=dst["uuid"],
|
||||
dst_name=dst["name"],
|
||||
dst_ip=dst["ip"],
|
||||
)
|
||||
|
||||
dst = rng.choice(ssh_deckies)
|
||||
template, content_template = rng.choice(_FILE_TEMPLATES)
|
||||
ts = int(datetime.now(timezone.utc).timestamp())
|
||||
user = rng.choice(_USERS)
|
||||
path = template.format(ts=ts, user=user)
|
||||
content = content_template.format(ts=ts, user=user, n=rng.randint(1000, 99999))
|
||||
return FileAction(
|
||||
dst_uuid=dst["uuid"],
|
||||
dst_name=dst["name"],
|
||||
path=path,
|
||||
content=content,
|
||||
)
|
||||
Reference in New Issue
Block a user