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:
99
tests/orchestrator/test_ssh_driver.py
Normal file
99
tests/orchestrator/test_ssh_driver.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Driver tests with the docker subprocess mocked.
|
||||
|
||||
We don't need a real Docker daemon to validate the driver's contract:
|
||||
it boils down to "build an argv, call _run, classify the result". A
|
||||
dependency-injected ``_run`` keeps the tests hermetic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.orchestrator.drivers import ssh as ssh_driver
|
||||
from decnet.orchestrator.drivers.base import ActivityResult
|
||||
from decnet.orchestrator.scheduler import FileAction, TrafficAction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traffic_success_classifies_on_ssh_banner(monkeypatch):
|
||||
captured_argv: list[list[str]] = []
|
||||
|
||||
async def fake_run(argv):
|
||||
captured_argv.append(argv)
|
||||
return 0, "SSH-2.0-OpenSSH_9.6\r\n", ""
|
||||
|
||||
monkeypatch.setattr(ssh_driver, "_run", fake_run)
|
||||
drv = ssh_driver.SSHDriver()
|
||||
action = TrafficAction(
|
||||
src_uuid="u1", src_name="decky-01",
|
||||
dst_uuid="u2", dst_name="decky-02",
|
||||
dst_ip="10.0.0.2",
|
||||
)
|
||||
result = await drv.run(action)
|
||||
assert isinstance(result, ActivityResult)
|
||||
assert result.success is True
|
||||
assert result.payload["banner"].startswith("SSH-2.0-OpenSSH")
|
||||
assert captured_argv[0][:3] == ["docker", "exec", "decky-01-ssh"]
|
||||
assert captured_argv[0][-1] == "10.0.0.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traffic_failure_when_banner_missing(monkeypatch):
|
||||
async def fake_run(argv):
|
||||
return 1, "", "Connection refused"
|
||||
|
||||
monkeypatch.setattr(ssh_driver, "_run", fake_run)
|
||||
drv = ssh_driver.SSHDriver()
|
||||
action = TrafficAction(
|
||||
src_uuid="u1", src_name="decky-01",
|
||||
dst_uuid="u2", dst_name="decky-02",
|
||||
dst_ip="10.0.0.2",
|
||||
)
|
||||
result = await drv.run(action)
|
||||
assert result.success is False
|
||||
assert result.payload["rc"] == 1
|
||||
assert "Connection refused" in result.payload["stderr"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_action_invokes_docker_exec_on_dst(monkeypatch):
|
||||
captured_argv: list[list[str]] = []
|
||||
|
||||
async def fake_run(argv):
|
||||
captured_argv.append(argv)
|
||||
return 0, "", ""
|
||||
|
||||
monkeypatch.setattr(ssh_driver, "_run", fake_run)
|
||||
drv = ssh_driver.SSHDriver()
|
||||
action = FileAction(
|
||||
dst_uuid="u2", dst_name="decky-02",
|
||||
path="/tmp/.cache-1700000000.tmp",
|
||||
content="session=1700000000\n",
|
||||
)
|
||||
result = await drv.run(action)
|
||||
assert result.success is True
|
||||
assert result.payload["bytes"] == len("session=1700000000\n".encode())
|
||||
argv = captured_argv[0]
|
||||
assert argv[:3] == ["docker", "exec", "decky-02-ssh"]
|
||||
assert argv[3] == "sh"
|
||||
assert argv[4] == "-c"
|
||||
# The shell payload must single-quote both the content and the path —
|
||||
# any unquoted ``;`` or ``$`` here would mean a shell-injection bug.
|
||||
sh_cmd = argv[5]
|
||||
# Path appears (shlex.quote leaves safe paths unquoted) and content
|
||||
# is single-quoted — that's the shell-injection-safe contract.
|
||||
assert "/tmp/.cache-1700000000.tmp" in sh_cmd
|
||||
assert "'session=1700000000\n'" in sh_cmd
|
||||
assert "mkdir -p /tmp" in sh_cmd
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_handles_missing_docker_binary(monkeypatch):
|
||||
async def fake_create(*args, **kwargs):
|
||||
raise FileNotFoundError("docker")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"asyncio.create_subprocess_exec", fake_create,
|
||||
)
|
||||
rc, out, err = await ssh_driver._run(["docker", "exec", "x", "true"])
|
||||
assert rc == 127
|
||||
assert "not found" in err
|
||||
Reference in New Issue
Block a user