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:
0
decnet/services/__init__.py
Normal file
0
decnet/services/__init__.py
Normal file
36
decnet/services/base.py
Normal file
36
decnet/services/base.py
Normal 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
26
decnet/services/ftp.py
Normal 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
26
decnet/services/http.py
Normal 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
26
decnet/services/rdp.py
Normal 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
|
||||
43
decnet/services/registry.py
Normal file
43
decnet/services/registry.py
Normal 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
27
decnet/services/smb.py
Normal 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
30
decnet/services/ssh.py
Normal 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
|
||||
Reference in New Issue
Block a user