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:
@@ -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
158
tests/test_ini_loader.py
Normal 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 == []
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user