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.
98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
"""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,
|
|
)
|