Add per-service customization, stealth hardening, and BYOS support

- HTTP: configurable server_header, response_code, fake_app presets
  (apache/nginx/wordpress/phpmyadmin/iis), extra_headers, custom_body,
  static files directory mount
- SSH/Cowrie: configurable kernel_version, hardware_platform, ssh_banner,
  and users/passwords via COWRIE_USERDB_ENTRIES; switched to build mode
  so cowrie.cfg.j2 persona fields and userdb.txt generation work
- SMTP: configurable banner and MTA hostname
- MySQL: configurable version string in protocol greeting
- Redis: configurable redis_version and os string in INFO response
- BYOS: [custom-*] INI sections define bring-your-own Docker services
- Stealth: rename all *_honeypot.py → server.py; replace HONEYPOT_NAME
  env var with NODE_NAME across all 22+ service templates and plugins;
  strip "honeypot" from all in-container file content
- Config: DeckyConfig.service_config dict; INI [decky-N.svc] subsections;
  composer passes service_cfg to compose_fragment
- 350 tests passing (100%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 04:08:27 -03:00
parent 07c06e3c0a
commit cf1e00af28
102 changed files with 974 additions and 309 deletions

View File

@@ -15,7 +15,12 @@ class BaseService(ABC):
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:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
"""
Return the docker-compose service dict for this service on a given decky.
@@ -26,6 +31,7 @@ class BaseService(ABC):
Args:
decky_name: unique identifier for the decky (e.g. "decky-01")
log_target: "ip:port" string if log forwarding is enabled, else None
service_cfg: optional per-service persona config from INI subsection
"""
def dockerfile_context(self) -> Path | None:

View File

@@ -12,7 +12,7 @@ class ConpotService(BaseService):
ports = [502, 161, 80]
default_image = "honeynet/conpot"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
return {
"image": "honeynet/conpot",
"container_name": f"{decky_name}-conpot",

View File

@@ -9,12 +9,12 @@ class DockerAPIService(BaseService):
ports = [2375, 2376]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-docker-api",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -10,13 +10,13 @@ class ElasticsearchService(BaseService):
ports = [9200]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-elasticsearch",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
"NODE_NAME": decky_name,
},
}
if log_target:

View File

@@ -9,13 +9,13 @@ class FTPService(BaseService):
ports = [21]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ftp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
"NODE_NAME": decky_name,
},
}
if log_target:

View File

@@ -1,3 +1,4 @@
import json
from pathlib import Path
from decnet.services.base import BaseService
@@ -9,17 +10,43 @@ class HTTPService(BaseService):
ports = [80, 443]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-http",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
"NODE_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
# Optional persona overrides — only injected when explicitly set
if "server_header" in cfg:
fragment["environment"]["SERVER_HEADER"] = cfg["server_header"]
if "response_code" in cfg:
fragment["environment"]["RESPONSE_CODE"] = str(cfg["response_code"])
if "fake_app" in cfg:
fragment["environment"]["FAKE_APP"] = cfg["fake_app"]
if "extra_headers" in cfg:
val = cfg["extra_headers"]
fragment["environment"]["EXTRA_HEADERS"] = (
json.dumps(val) if isinstance(val, dict) else val
)
if "custom_body" in cfg:
fragment["environment"]["CUSTOM_BODY"] = cfg["custom_body"]
if "files" in cfg:
files_path = str(Path(cfg["files"]).resolve())
fragment["environment"]["FILES_DIR"] = "/opt/html_files"
fragment.setdefault("volumes", []).append(f"{files_path}:/opt/html_files:ro")
return fragment
def dockerfile_context(self) -> Path | None:

View File

@@ -9,12 +9,12 @@ class IMAPService(BaseService):
ports = [143, 993]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-imap",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class KubernetesAPIService(BaseService):
ports = [6443, 8080]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-k8s",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,13 +9,13 @@ class LDAPService(BaseService):
ports = [389, 636]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ldap",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -16,12 +16,12 @@ class LLMNRService(BaseService):
ports = [5355, 5353]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-llmnr",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class MongoDBService(BaseService):
ports = [27017]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-mongodb",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class MQTTService(BaseService):
ports = [1883]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-mqtt",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class MSSQLService(BaseService):
ports = [1433]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-mssql",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,15 +9,23 @@ class MySQLService(BaseService):
ports = [3306]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-mysql",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
if "version" in cfg:
fragment["environment"]["MYSQL_VERSION"] = cfg["version"]
return fragment
def dockerfile_context(self) -> Path | None:

View File

@@ -9,12 +9,12 @@ class POP3Service(BaseService):
ports = [110, 995]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-pop3",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class PostgresService(BaseService):
ports = [5432]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-postgres",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,13 +9,13 @@ class RDPService(BaseService):
ports = [3389]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-rdp",
"restart": "unless-stopped",
"environment": {
"HONEYPOT_NAME": decky_name,
"NODE_NAME": decky_name,
},
}
if log_target:

View File

@@ -9,15 +9,25 @@ class RedisService(BaseService):
ports = [6379]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-redis",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
if "version" in cfg:
fragment["environment"]["REDIS_VERSION"] = cfg["version"]
if "os_string" in cfg:
fragment["environment"]["REDIS_OS"] = cfg["os_string"]
return fragment
def dockerfile_context(self) -> Path | None:

View File

@@ -31,6 +31,12 @@ def _load_plugins() -> None:
_loaded = True
def register_custom_service(instance: BaseService) -> None:
"""Register a dynamically created service (e.g. BYOS from INI)."""
_load_plugins()
_registry[instance.name] = instance
def get_service(name: str) -> BaseService:
_load_plugins()
if name not in _registry:

View File

@@ -9,12 +9,12 @@ class SIPService(BaseService):
ports = [5060]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-sip",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,14 +9,14 @@ class SMBService(BaseService):
ports = [445, 139]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | 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,
"NODE_NAME": decky_name,
},
}
if log_target:

View File

@@ -10,18 +10,28 @@ class SMTPService(BaseService):
ports = [25, 587]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-smtp",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": {
"HONEYPOT_NAME": decky_name,
"NODE_NAME": decky_name,
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
if "banner" in cfg:
fragment["environment"]["SMTP_BANNER"] = cfg["banner"]
if "mta" in cfg:
fragment["environment"]["SMTP_MTA"] = cfg["mta"]
return fragment
def dockerfile_context(self) -> Path:

View File

@@ -9,12 +9,12 @@ class SNMPService(BaseService):
ports = [161]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-snmp",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -1,15 +1,24 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "cowrie"
class SSHService(BaseService):
name = "ssh"
ports = [22, 2222]
default_image = "cowrie/cowrie"
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
env: dict = {
# Override [honeypot] and [ssh] listen_endpoints to also bind port 22
"COWRIE_HONEYPOT_HOSTNAME": decky_name,
"NODE_NAME": decky_name,
"COWRIE_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",
}
@@ -18,13 +27,26 @@ class SSHService(BaseService):
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
# Optional persona overrides
if "kernel_version" in cfg:
env["COWRIE_HONEYPOT_KERNEL_VERSION"] = cfg["kernel_version"]
if "kernel_build_string" in cfg:
env["COWRIE_HONEYPOT_KERNEL_BUILD_STRING"] = cfg["kernel_build_string"]
if "hardware_platform" in cfg:
env["COWRIE_HONEYPOT_HARDWARE_PLATFORM"] = cfg["hardware_platform"]
if "ssh_banner" in cfg:
env["COWRIE_SSH_VERSION"] = cfg["ssh_banner"]
if "users" in cfg:
env["COWRIE_USERDB_ENTRIES"] = cfg["users"]
return {
"image": "cowrie/cowrie",
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-ssh",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": env,
}
def dockerfile_context(self):
return None
def dockerfile_context(self) -> Path:
return TEMPLATES_DIR

View File

@@ -6,7 +6,7 @@ class TelnetService(BaseService):
ports = [23]
default_image = "cowrie/cowrie"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
env: dict = {
"COWRIE_HONEYPOT_HOSTNAME": decky_name,
"COWRIE_TELNET_ENABLED": "true",

View File

@@ -9,12 +9,12 @@ class TFTPService(BaseService):
ports = [69]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-tftp",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target

View File

@@ -9,12 +9,12 @@ class VNCService(BaseService):
ports = [5900]
default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None) -> dict:
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-vnc",
"restart": "unless-stopped",
"environment": {"HONEYPOT_NAME": decky_name},
"environment": {"NODE_NAME": decky_name},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target