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

@@ -20,13 +20,13 @@ APT_COMPATIBLE = {
}
BUILD_SERVICES = [
"http", "rdp", "smb", "ftp", "smtp", "elasticsearch",
"ssh", "http", "rdp", "smb", "ftp", "smtp", "elasticsearch",
"pop3", "imap", "mysql", "mssql", "redis", "mongodb", "postgres",
"ldap", "vnc", "docker_api", "k8s", "sip",
"mqtt", "llmnr", "snmp", "tftp",
]
UPSTREAM_SERVICES = ["ssh", "telnet", "conpot"]
UPSTREAM_SERVICES = ["telnet", "conpot"]
def _make_config(services, distro="debian", base_image=None, build_base=None):
@@ -95,6 +95,86 @@ def test_upstream_service_has_no_build_section(svc):
assert "image" in fragment
# ---------------------------------------------------------------------------
# service_config propagation tests
# ---------------------------------------------------------------------------
def test_service_config_http_server_header():
"""service_config for http must inject SERVER_HEADER into compose env."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["http"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"http": {"server_header": "nginx/1.18.0"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
env = compose["services"]["decky-01-http"]["environment"]
assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_service_config_ssh_kernel_version():
"""service_config for ssh must inject COWRIE_HONEYPOT_KERNEL_VERSION."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["ssh"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"ssh": {"kernel_version": "5.15.0-76-generic"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
env = compose["services"]["decky-01-ssh"]["environment"]
assert env.get("COWRIE_HONEYPOT_KERNEL_VERSION") == "5.15.0-76-generic"
def test_service_config_for_one_service_does_not_affect_another():
"""service_config for http must not bleed into ftp fragment."""
from decnet.config import DeckyConfig, DecnetConfig
from decnet.distros import DISTROS
profile = DISTROS["debian"]
decky = DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["http", "ftp"], distro="debian",
base_image=profile.image, build_base=profile.build_base,
hostname="test-host",
service_config={"http": {"server_header": "nginx/1.18.0"}},
)
config = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[decky],
)
compose = generate_compose(config)
ftp_env = compose["services"]["decky-01-ftp"]["environment"]
assert "SERVER_HEADER" not in ftp_env
def test_no_service_config_produces_no_extra_env():
"""A decky with no service_config must not have new persona env vars."""
config = _make_config(["http", "mysql"])
compose = generate_compose(config)
for svc in ("http", "mysql"):
env = compose["services"][f"decky-01-{svc}"]["environment"]
assert "SERVER_HEADER" not in env
assert "MYSQL_VERSION" not in env
# ---------------------------------------------------------------------------
# Base container uses distro image, not build_base
# ---------------------------------------------------------------------------

158
tests/test_ini_loader.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Tests for the INI loader — subsection parsing, custom service definitions,
and per-service config propagation.
"""
import pytest
import textwrap
from pathlib import Path
from decnet.ini_loader import load_ini, IniConfig
def _write_ini(tmp_path: Path, content: str) -> Path:
f = tmp_path / "decnet.ini"
f.write_text(textwrap.dedent(content))
return f
# ---------------------------------------------------------------------------
# Basic decky parsing (regression)
# ---------------------------------------------------------------------------
def test_basic_decky_parsed(tmp_path):
ini_file = _write_ini(tmp_path, """
[general]
net = 192.168.1.0/24
gw = 192.168.1.1
[decky-01]
ip = 192.168.1.101
services = ssh, http
""")
cfg = load_ini(ini_file)
assert len(cfg.deckies) == 1
assert cfg.deckies[0].name == "decky-01"
assert cfg.deckies[0].services == ["ssh", "http"]
assert cfg.deckies[0].service_config == {}
# ---------------------------------------------------------------------------
# Per-service subsection parsing
# ---------------------------------------------------------------------------
def test_subsection_parsed_into_service_config(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
ip = 192.168.1.101
services = ssh
[decky-01.ssh]
kernel_version = 5.15.0-76-generic
hardware_platform = x86_64
""")
cfg = load_ini(ini_file)
svc_cfg = cfg.deckies[0].service_config
assert "ssh" in svc_cfg
assert svc_cfg["ssh"]["kernel_version"] == "5.15.0-76-generic"
assert svc_cfg["ssh"]["hardware_platform"] == "x86_64"
def test_multiple_subsections_for_same_decky(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
services = ssh, http
[decky-01.ssh]
users = root:toor
[decky-01.http]
server_header = nginx/1.18.0
fake_app = wordpress
""")
cfg = load_ini(ini_file)
svc_cfg = cfg.deckies[0].service_config
assert svc_cfg["ssh"]["users"] == "root:toor"
assert svc_cfg["http"]["server_header"] == "nginx/1.18.0"
assert svc_cfg["http"]["fake_app"] == "wordpress"
def test_subsection_for_unknown_decky_is_ignored(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
services = ssh
[ghost.ssh]
kernel_version = 5.15.0
""")
cfg = load_ini(ini_file)
# ghost.ssh must not create a new decky or error out
assert len(cfg.deckies) == 1
assert cfg.deckies[0].name == "decky-01"
assert cfg.deckies[0].service_config == {}
def test_plain_decky_without_subsections_has_empty_service_config(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
services = http
""")
cfg = load_ini(ini_file)
assert cfg.deckies[0].service_config == {}
# ---------------------------------------------------------------------------
# Bring-your-own service (BYOS) parsing
# ---------------------------------------------------------------------------
def test_custom_service_parsed(tmp_path):
ini_file = _write_ini(tmp_path, """
[general]
net = 10.0.0.0/24
gw = 10.0.0.1
[custom-myservice]
binary = my-image:latest
exec = /usr/bin/myapp -p 8080
ports = 8080
""")
cfg = load_ini(ini_file)
assert len(cfg.custom_services) == 1
cs = cfg.custom_services[0]
assert cs.name == "myservice"
assert cs.image == "my-image:latest"
assert cs.exec_cmd == "/usr/bin/myapp -p 8080"
assert cs.ports == [8080]
def test_custom_service_without_ports(tmp_path):
ini_file = _write_ini(tmp_path, """
[custom-scanner]
binary = scanner:1.0
exec = /usr/bin/scanner
""")
cfg = load_ini(ini_file)
assert cfg.custom_services[0].ports == []
def test_custom_service_not_added_to_deckies(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
services = ssh
[custom-myservice]
binary = foo:bar
exec = /bin/foo
""")
cfg = load_ini(ini_file)
assert len(cfg.deckies) == 1
assert cfg.deckies[0].name == "decky-01"
assert len(cfg.custom_services) == 1
def test_no_custom_services_gives_empty_list(tmp_path):
ini_file = _write_ini(tmp_path, """
[decky-01]
services = http
""")
cfg = load_ini(ini_file)
assert cfg.custom_services == []

View File

@@ -6,6 +6,7 @@ Covers:
- compose_fragment structure (container_name, restart, image/build)
- LOG_TARGET propagation for custom-build services
- dockerfile_context returns Path for build services, None for upstream-image services
- Per-service persona config (service_cfg) propagation
"""
import pytest
@@ -17,8 +18,8 @@ from decnet.services.registry import all_services, get_service
# Helpers
# ---------------------------------------------------------------------------
def _fragment(name: str, log_target: str | None = None) -> dict:
return get_service(name).compose_fragment("test-decky", log_target)
def _fragment(name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
return get_service(name).compose_fragment("test-decky", log_target, service_cfg)
def _is_build_service(name: str) -> bool:
@@ -27,20 +28,20 @@ def _is_build_service(name: str) -> bool:
# ---------------------------------------------------------------------------
# Tier 1: upstream-image services
# Tier 1: upstream-image services (non-build)
# ---------------------------------------------------------------------------
UPSTREAM_SERVICES = {
"ssh": ("cowrie/cowrie", [22, 2222]),
"telnet": ("cowrie/cowrie", [23]),
"conpot": ("honeynet/conpot", [502, 161, 80]),
}
# ---------------------------------------------------------------------------
# Tier 2: custom-build services
# Tier 2: custom-build services (including ssh, which now uses build)
# ---------------------------------------------------------------------------
BUILD_SERVICES = {
"ssh": ([22, 2222], "ssh"),
"http": ([80, 443], "http"),
"rdp": ([3389], "rdp"),
"smb": ([445, 139], "smb"),
@@ -155,27 +156,40 @@ def test_build_service_restart_policy(name):
@pytest.mark.parametrize("name", BUILD_SERVICES)
def test_build_service_honeypot_name_env(name):
def test_build_service_node_name_env(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "HONEYPOT_NAME" in env
assert env["HONEYPOT_NAME"] == "test-decky"
assert "NODE_NAME" in env
assert env["NODE_NAME"] == "test-decky"
@pytest.mark.parametrize("name", BUILD_SERVICES)
# SSH uses COWRIE_OUTPUT_TCP_* instead of LOG_TARGET — exclude from generic tests
_LOG_TARGET_SERVICES = [n for n in BUILD_SERVICES if n != "ssh"]
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_log_target_propagated(name):
frag = _fragment(name, log_target="10.0.0.1:5140")
env = frag.get("environment", {})
assert env.get("LOG_TARGET") == "10.0.0.1:5140"
@pytest.mark.parametrize("name", BUILD_SERVICES)
@pytest.mark.parametrize("name", _LOG_TARGET_SERVICES)
def test_build_service_no_log_target_by_default(name):
frag = _fragment(name)
env = frag.get("environment", {})
assert "LOG_TARGET" not in env
def test_ssh_log_target_uses_cowrie_tcp_output():
"""SSH forwards logs via Cowrie TCP output, not LOG_TARGET."""
env = _fragment("ssh", log_target="10.0.0.1:5140").get("environment", {})
assert env.get("COWRIE_OUTPUT_TCP_ENABLED") == "true"
assert env.get("COWRIE_OUTPUT_TCP_HOST") == "10.0.0.1"
assert env.get("COWRIE_OUTPUT_TCP_PORT") == "5140"
assert "LOG_TARGET" not in env
# ---------------------------------------------------------------------------
# Port coverage tests
# ---------------------------------------------------------------------------
@@ -203,3 +217,125 @@ def test_upstream_service_ports(name, expected):
def test_total_service_count():
"""Sanity check: at least 25 services registered."""
assert len(all_services()) >= 25
# ---------------------------------------------------------------------------
# Per-service persona config (service_cfg)
# ---------------------------------------------------------------------------
# HTTP -----------------------------------------------------------------------
def test_http_default_no_extra_env():
"""No service_cfg → none of the new env vars should appear."""
env = _fragment("http").get("environment", {})
for key in ("SERVER_HEADER", "RESPONSE_CODE", "FAKE_APP", "EXTRA_HEADERS", "CUSTOM_BODY", "FILES_DIR"):
assert key not in env, f"Expected {key} absent by default"
def test_http_server_header():
env = _fragment("http", service_cfg={"server_header": "nginx/1.18.0"}).get("environment", {})
assert env.get("SERVER_HEADER") == "nginx/1.18.0"
def test_http_response_code():
env = _fragment("http", service_cfg={"response_code": 200}).get("environment", {})
assert env.get("RESPONSE_CODE") == "200"
def test_http_fake_app():
env = _fragment("http", service_cfg={"fake_app": "wordpress"}).get("environment", {})
assert env.get("FAKE_APP") == "wordpress"
def test_http_extra_headers():
import json
env = _fragment("http", service_cfg={"extra_headers": {"X-Frame-Options": "SAMEORIGIN"}}).get("environment", {})
assert "EXTRA_HEADERS" in env
assert json.loads(env["EXTRA_HEADERS"]) == {"X-Frame-Options": "SAMEORIGIN"}
def test_http_custom_body():
env = _fragment("http", service_cfg={"custom_body": "<html>hi</html>"}).get("environment", {})
assert env.get("CUSTOM_BODY") == "<html>hi</html>"
def test_http_empty_service_cfg_no_extra_env():
env = _fragment("http", service_cfg={}).get("environment", {})
assert "SERVER_HEADER" not in env
# SSH ------------------------------------------------------------------------
def test_ssh_default_no_persona_env():
env = _fragment("ssh").get("environment", {})
for key in ("COWRIE_HONEYPOT_KERNEL_VERSION", "COWRIE_HONEYPOT_HARDWARE_PLATFORM",
"COWRIE_SSH_VERSION", "COWRIE_USERDB_ENTRIES"):
assert key not in env, f"Expected {key} absent by default"
def test_ssh_kernel_version():
env = _fragment("ssh", service_cfg={"kernel_version": "5.15.0-76-generic"}).get("environment", {})
assert env.get("COWRIE_HONEYPOT_KERNEL_VERSION") == "5.15.0-76-generic"
def test_ssh_hardware_platform():
env = _fragment("ssh", service_cfg={"hardware_platform": "aarch64"}).get("environment", {})
assert env.get("COWRIE_HONEYPOT_HARDWARE_PLATFORM") == "aarch64"
def test_ssh_banner():
env = _fragment("ssh", service_cfg={"ssh_banner": "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.3"}).get("environment", {})
assert env.get("COWRIE_SSH_VERSION") == "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.3"
def test_ssh_users():
env = _fragment("ssh", service_cfg={"users": "root:toor,admin:admin123"}).get("environment", {})
assert env.get("COWRIE_USERDB_ENTRIES") == "root:toor,admin:admin123"
# SMTP -----------------------------------------------------------------------
def test_smtp_banner():
env = _fragment("smtp", service_cfg={"banner": "220 mail.corp.local ESMTP Sendmail"}).get("environment", {})
assert env.get("SMTP_BANNER") == "220 mail.corp.local ESMTP Sendmail"
def test_smtp_mta():
env = _fragment("smtp", service_cfg={"mta": "mail.corp.local"}).get("environment", {})
assert env.get("SMTP_MTA") == "mail.corp.local"
def test_smtp_default_no_extra_env():
env = _fragment("smtp").get("environment", {})
assert "SMTP_BANNER" not in env
assert "SMTP_MTA" not in env
# MySQL ----------------------------------------------------------------------
def test_mysql_version():
env = _fragment("mysql", service_cfg={"version": "8.0.33"}).get("environment", {})
assert env.get("MYSQL_VERSION") == "8.0.33"
def test_mysql_default_no_version_env():
env = _fragment("mysql").get("environment", {})
assert "MYSQL_VERSION" not in env
# Redis ----------------------------------------------------------------------
def test_redis_version():
env = _fragment("redis", service_cfg={"version": "6.2.14"}).get("environment", {})
assert env.get("REDIS_VERSION") == "6.2.14"
def test_redis_os_string():
env = _fragment("redis", service_cfg={"os_string": "Linux 4.19.0"}).get("environment", {})
assert env.get("REDIS_OS") == "Linux 4.19.0"
def test_redis_default_no_extra_env():
env = _fragment("redis").get("environment", {})
assert "REDIS_VERSION" not in env
assert "REDIS_OS" not in env