Initial commit: DECNET honeypot/deception network framework

Core CLI, service plugins (SSH/SMB/FTP/HTTP/RDP), Docker Compose
orchestration, MACVLAN networking, and Logstash log forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:56:25 -03:00
commit 3e98c71ca4
37 changed files with 1822 additions and 0 deletions

View File

36
decnet/services/base.py Normal file
View File

@@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from pathlib import Path
class BaseService(ABC):
"""
Contract every honeypot service plugin must implement.
To add a new service: subclass BaseService in a new file under decnet/services/.
The registry auto-discovers all subclasses at import time.
"""
name: str # unique slug, e.g. "ssh", "smb"
ports: list[int] # ports this service listens on inside the container
default_image: str # Docker image tag, or "build" if a Dockerfile is needed
@abstractmethod
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
"""
Return the docker-compose service dict for this service on a given decky.
Networking keys (networks, ipv4_address) are injected by the composer —
do NOT include them here. Include: image/build, environment, volumes,
restart, and any service-specific options.
Args:
decky_name: unique identifier for the decky (e.g. "decky-01")
log_target: "ip:port" string if log forwarding is enabled, else None
"""
def dockerfile_context(self) -> Path | None:
"""
Return path to the build context directory if this service needs a custom
image built. Return None if default_image is used directly.
"""
return None

26
decnet/services/ftp.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "ftp"
class FTPService(BaseService):
name = "ftp"
ports = [21]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ftp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

26
decnet/services/http.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "http"
class HTTPService(BaseService):
name = "http"
ports = [80, 443]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-http",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

26
decnet/services/rdp.py Normal file
View File

@@ -0,0 +1,26 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "rdp"
class RDPService(BaseService):
name = "rdp"
ports = [3389]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-rdp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

View File

@@ -0,0 +1,43 @@
"""
Service plugin registry.
Auto-discovers all BaseService subclasses by importing every module in the
services package. Adding a new service requires nothing beyond dropping a
new .py file here that subclasses BaseService.
"""
import importlib
import pkgutil
from pathlib import Path
from decnet.services.base import BaseService
_registry: dict[str, BaseService] = {}
_loaded = False
def _load_plugins() -> None:
global _loaded
if _loaded:
return
package_dir = Path(__file__).parent
for module_info in pkgutil.iter_modules([str(package_dir)]):
if module_info.name in ("base", "registry"):
continue
importlib.import_module(f"decnet.services.{module_info.name}")
for cls in BaseService.__subclasses__():
instance = cls()
_registry[instance.name] = instance
_loaded = True
def get_service(name: str) -> BaseService:
_load_plugins()
if name not in _registry:
raise KeyError(f"Unknown service: '{name}'. Available: {list(_registry)}")
return _registry[name]
def all_services() -> dict[str, BaseService]:
_load_plugins()
return dict(_registry)

27
decnet/services/smb.py Normal file
View File

@@ -0,0 +1,27 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "smb"
class SMBService(BaseService):
name = "smb"
ports = [445, 139]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-smb",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": {
"HONEYPOT_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
return fragment
def dockerfile_context(self) -> Path | None:
return TEMPLATES_DIR

30
decnet/services/ssh.py Normal file
View File

@@ -0,0 +1,30 @@
from decnet.services.base import BaseService
class SSHService(BaseService):
name = "ssh"
ports = [22, 2222]
default_image = "cowrie/cowrie"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
env: dict = {
# Override [honeypot] and [ssh] listen_endpoints to also bind port 22
"COWRIE_HONEYPOT_HOSTNAME": decky_name,
"COWRIE_HONEYPOT_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
"COWRIE_SSH_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
}
if log_target:
host, port = log_target.rsplit(":", 1)
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
return {
"image": "cowrie/cowrie",
"container_name": f"{decky_name}-ssh",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": env,
}
def dockerfile_context(self):
return None