From cf1e00af28afec7951a2fc38e4701b06bdd15489 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 4 Apr 2026 04:08:27 -0300 Subject: [PATCH] Add per-service customization, stealth hardening, and BYOS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 7 + CLAUDE.md | 8 + decnet/cli.py | 15 ++ decnet/composer.py | 5 +- decnet/config.py | 1 + decnet/custom_service.py | 41 +++++ decnet/ini_loader.py | 53 ++++++ decnet/services/base.py | 8 +- decnet/services/conpot.py | 2 +- decnet/services/docker_api.py | 4 +- decnet/services/elasticsearch.py | 4 +- decnet/services/ftp.py | 4 +- decnet/services/http.py | 31 +++- decnet/services/imap.py | 4 +- decnet/services/k8s.py | 4 +- decnet/services/ldap.py | 4 +- decnet/services/llmnr.py | 4 +- decnet/services/mongodb.py | 4 +- decnet/services/mqtt.py | 4 +- decnet/services/mssql.py | 4 +- decnet/services/mysql.py | 12 +- decnet/services/pop3.py | 4 +- decnet/services/postgres.py | 4 +- decnet/services/rdp.py | 4 +- decnet/services/redis.py | 14 +- decnet/services/registry.py | 6 + decnet/services/sip.py | 4 +- decnet/services/smb.py | 4 +- decnet/services/smtp.py | 14 +- decnet/services/snmp.py | 4 +- decnet/services/ssh.py | 36 +++- decnet/services/telnet.py | 2 +- decnet/services/tftp.py | 4 +- decnet/services/vnc.py | 4 +- templates/cowrie/cowrie.cfg.j2 | 4 + templates/cowrie/entrypoint.sh | 17 +- templates/docker_api/Dockerfile | 2 +- templates/docker_api/entrypoint.sh | 2 +- .../{docker_api_honeypot.py => server.py} | 10 +- templates/elasticsearch/Dockerfile | 2 +- templates/elasticsearch/entrypoint.sh | 2 +- .../{elasticsearch_honeypot.py => server.py} | 12 +- templates/ftp/Dockerfile | 2 +- templates/ftp/entrypoint.sh | 2 +- templates/ftp/{ftp_honeypot.py => server.py} | 24 +-- templates/http/Dockerfile | 2 +- templates/http/entrypoint.sh | 2 +- templates/http/http_honeypot.py | 69 -------- templates/http/server.py | 118 +++++++++++++ templates/imap/Dockerfile | 2 +- templates/imap/entrypoint.sh | 2 +- .../imap/{imap_honeypot.py => server.py} | 10 +- templates/k8s/{k8s_honeypot.py => server.py} | 10 +- templates/ldap/Dockerfile | 2 +- templates/ldap/entrypoint.sh | 2 +- .../ldap/{ldap_honeypot.py => server.py} | 8 +- templates/llmnr/Dockerfile | 2 +- templates/llmnr/entrypoint.sh | 2 +- .../llmnr/{llmnr_honeypot.py => server.py} | 6 +- templates/mongodb/Dockerfile | 2 +- templates/mongodb/entrypoint.sh | 2 +- .../{mongodb_honeypot.py => server.py} | 8 +- templates/mqtt/Dockerfile | 2 +- templates/mqtt/entrypoint.sh | 2 +- .../mqtt/{mqtt_honeypot.py => server.py} | 8 +- templates/mssql/Dockerfile | 2 +- templates/mssql/entrypoint.sh | 2 +- .../mssql/{mssql_honeypot.py => server.py} | 8 +- templates/mysql/Dockerfile | 2 +- templates/mysql/entrypoint.sh | 2 +- .../mysql/{mysql_honeypot.py => server.py} | 37 ++-- .../pop3/{pop3_honeypot.py => server.py} | 10 +- templates/postgres/Dockerfile | 2 +- templates/postgres/entrypoint.sh | 2 +- .../{postgres_honeypot.py => server.py} | 8 +- templates/rdp/Dockerfile | 2 +- templates/rdp/entrypoint.sh | 2 +- templates/rdp/{rdp_honeypot.py => server.py} | 16 +- templates/redis/Dockerfile | 2 +- templates/redis/entrypoint.sh | 2 +- .../redis/{redis_honeypot.py => server.py} | 33 ++-- templates/sip/Dockerfile | 2 +- templates/sip/entrypoint.sh | 2 +- templates/sip/{sip_honeypot.py => server.py} | 10 +- templates/smb/Dockerfile | 2 +- templates/smb/entrypoint.sh | 2 +- templates/smb/{smb_honeypot.py => server.py} | 8 +- templates/smtp/Dockerfile | 2 +- templates/smtp/entrypoint.sh | 2 +- .../smtp/{smtp_honeypot.py => server.py} | 16 +- templates/snmp/Dockerfile | 2 +- templates/snmp/entrypoint.sh | 2 +- .../snmp/{snmp_honeypot.py => server.py} | 12 +- templates/tftp/Dockerfile | 2 +- templates/tftp/entrypoint.sh | 2 +- .../tftp/{tftp_honeypot.py => server.py} | 8 +- templates/vnc/Dockerfile | 2 +- templates/vnc/entrypoint.sh | 2 +- templates/vnc/{vnc_honeypot.py => server.py} | 8 +- tests/test_composer.py | 84 +++++++++- tests/test_ini_loader.py | 158 ++++++++++++++++++ tests/test_services.py | 156 +++++++++++++++-- 102 files changed, 974 insertions(+), 309 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 decnet/custom_service.py rename templates/docker_api/{docker_api_honeypot.py => server.py} (93%) rename templates/elasticsearch/{elasticsearch_honeypot.py => server.py} (94%) rename templates/ftp/{ftp_honeypot.py => server.py} (74%) delete mode 100644 templates/http/http_honeypot.py create mode 100644 templates/http/server.py rename templates/imap/{imap_honeypot.py => server.py} (92%) rename templates/k8s/{k8s_honeypot.py => server.py} (93%) rename templates/ldap/{ldap_honeypot.py => server.py} (96%) rename templates/llmnr/{llmnr_honeypot.py => server.py} (95%) rename templates/mongodb/{mongodb_honeypot.py => server.py} (95%) rename templates/mqtt/{mqtt_honeypot.py => server.py} (95%) rename templates/mssql/{mssql_honeypot.py => server.py} (96%) rename templates/mysql/{mysql_honeypot.py => server.py} (74%) rename templates/pop3/{pop3_honeypot.py => server.py} (92%) rename templates/postgres/{postgres_honeypot.py => server.py} (95%) rename templates/rdp/{rdp_honeypot.py => server.py} (82%) rename templates/redis/{redis_honeypot.py => server.py} (88%) rename templates/sip/{sip_honeypot.py => server.py} (94%) rename templates/smb/{smb_honeypot.py => server.py} (84%) rename templates/smtp/{smtp_honeypot.py => server.py} (88%) rename templates/snmp/{snmp_honeypot.py => server.py} (94%) rename templates/tftp/{tftp_honeypot.py => server.py} (93%) rename templates/vnc/{vnc_honeypot.py => server.py} (94%) create mode 100644 tests/test_ini_loader.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..02b56d2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__plugin_context-mode_context-mode__ctx_batch_execute" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 8212cb6..b1b13c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,3 +46,11 @@ DECNET is a honeypot/deception network framework. It deploys fake machines (call - The logging/aggregation network must be isolated from the decoy network. - A publicly accessible real server acts as the bridge between the two networks. - Deckies should differ in exposed services and OS fingerprints to appear as a heterogeneous network. + +## Development and testing + +- For every new feature, pytests must me made. +- Pytest is the main testing framework in use. +- NEVER pass broken code to the user. + - Broken means: not running, not passing 100% tests, etc. +- After tests pass with 100%, always git commit your changes. diff --git a/decnet/cli.py b/decnet/cli.py index 3336dba..373a75f 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -168,6 +168,7 @@ def _build_deckies_from_ini( base_image=distro.image, build_base=distro.build_base, hostname=hostname, + service_config=spec.service_config, )) return deckies @@ -217,6 +218,20 @@ def deploy( f"[dim]Subnet:[/] {subnet_cidr} [dim]Gateway:[/] {effective_gateway} " f"[dim]Host IP:[/] {host_ip}") + # Register bring-your-own services from INI before validation + if ini.custom_services: + from decnet.custom_service import CustomService + from decnet.services.registry import register_custom_service + for cs in ini.custom_services: + register_custom_service( + CustomService( + name=cs.name, + image=cs.image, + exec_cmd=cs.exec_cmd, + ports=cs.ports, + ) + ) + effective_log_target = log_target or ini.log_target decky_configs = _build_deckies_from_ini( ini, subnet_cidr, effective_gateway, host_ip, randomize_services diff --git a/decnet/composer.py b/decnet/composer.py index 6c092f1..280b630 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -46,7 +46,10 @@ def generate_compose(config: DecnetConfig) -> dict: # --- Service containers: share base network namespace --- for svc_name in decky.services: svc = get_service(svc_name) - fragment = svc.compose_fragment(decky.name, log_target=config.log_target) + svc_cfg = decky.service_config.get(svc_name, {}) + fragment = svc.compose_fragment( + decky.name, log_target=config.log_target, service_cfg=svc_cfg + ) # Inject the per-decky base image into build services so containers # vary by distro and don't all fingerprint as debian:bookworm-slim. diff --git a/decnet/config.py b/decnet/config.py index 5adfbf8..44bdfef 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -26,6 +26,7 @@ class DeckyConfig(BaseModel): base_image: str # Docker image for the base/IP-holder container build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles hostname: str + service_config: dict[str, dict] = {} # optional per-service persona config @field_validator("services") @classmethod diff --git a/decnet/custom_service.py b/decnet/custom_service.py new file mode 100644 index 0000000..6f28ffd --- /dev/null +++ b/decnet/custom_service.py @@ -0,0 +1,41 @@ +""" +Bring-your-own-service (BYOS) support. + +CustomService wraps a user-defined service from an INI [custom-*] section. +It is instantiated dynamically and registered via register_custom_service(), +not through the auto-discovery mechanism in the registry. +""" + +from decnet.services.base import BaseService + + +class CustomService(BaseService): + """A user-defined service that runs an arbitrary Docker image.""" + + def __init__(self, name: str, image: str, exec_cmd: str, ports: list[int] | None = None): + self.name = name + self.default_image = image + self.ports = ports or [] + self._exec_cmd = exec_cmd + + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + slug = self.name.replace("_", "-") + fragment: dict = { + "image": self.default_image, + "container_name": f"{decky_name}-{slug}", + "restart": "unless-stopped", + "environment": {"NODE_NAME": decky_name}, + } + if self._exec_cmd: + fragment["command"] = self._exec_cmd.split() + if log_target: + fragment["environment"]["LOG_TARGET"] = log_target + return fragment + + def dockerfile_context(self): + return None diff --git a/decnet/ini_loader.py b/decnet/ini_loader.py index 43b78b2..65b0263 100644 --- a/decnet/ini_loader.py +++ b/decnet/ini_loader.py @@ -12,11 +12,26 @@ Format: ip=192.168.1.82 # optional services=ssh,smb # optional; falls back to --randomize-services + [hostname-1.ssh] # optional per-service persona config + kernel_version=5.15.0-76-generic + users=root:toor,admin:admin123 + + [hostname-1.http] + server_header=nginx/1.18.0 + fake_app=wordpress + [hostname-2] services=ssh [hostname-3] ip=192.168.1.32 + + # Custom (bring-your-own) service definitions: + [custom-myservice] + binary=my-docker-image:latest + exec=/usr/bin/myservice -p 8080 + ports=8080 + """ import configparser @@ -29,6 +44,16 @@ class DeckySpec: name: str ip: str | None = None services: list[str] | None = None + service_config: dict[str, dict] = field(default_factory=dict) + + +@dataclass +class CustomServiceSpec: + """Spec for a user-defined (bring-your-own) service.""" + name: str # service slug, e.g. "myservice" (section is "custom-myservice") + image: str # Docker image to use + exec_cmd: str # command to run inside the container + ports: list[int] = field(default_factory=list) @dataclass @@ -38,6 +63,7 @@ class IniConfig: interface: str | None = None log_target: str | None = None deckies: list[DeckySpec] = field(default_factory=list) + custom_services: list[CustomServiceSpec] = field(default_factory=list) def load_ini(path: str | Path) -> IniConfig: @@ -56,13 +82,40 @@ def load_ini(path: str | Path) -> IniConfig: cfg.interface = g.get("interface") cfg.log_target = g.get("log_target") or g.get("log-target") + # First pass: collect decky sections and custom service definitions for section in cp.sections(): if section == "general": continue + if "." in section: + continue # subsections handled in second pass + if section.startswith("custom-"): + # Bring-your-own service definition + s = cp[section] + svc_name = section[len("custom-"):] + image = s.get("binary", "") + exec_cmd = s.get("exec", "") + ports_raw = s.get("ports", "") + ports = [int(p.strip()) for p in ports_raw.split(",") if p.strip().isdigit()] + cfg.custom_services.append( + CustomServiceSpec(name=svc_name, image=image, exec_cmd=exec_cmd, ports=ports) + ) + continue s = cp[section] ip = s.get("ip") svc_raw = s.get("services") services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None cfg.deckies.append(DeckySpec(name=section, ip=ip, services=services)) + # Second pass: collect per-service subsections [decky-name.service] + decky_names = {d.name for d in cfg.deckies} + decky_map = {d.name: d for d in cfg.deckies} + for section in cp.sections(): + if "." not in section: + continue + decky_name, _, svc_name = section.partition(".") + if decky_name not in decky_names: + continue # orphaned subsection — ignore + svc_cfg = {k: v for k, v in cp[section].items()} + decky_map[decky_name].service_config[svc_name] = svc_cfg + return cfg diff --git a/decnet/services/base.py b/decnet/services/base.py index a2babbd..17c2e20 100644 --- a/decnet/services/base.py +++ b/decnet/services/base.py @@ -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: diff --git a/decnet/services/conpot.py b/decnet/services/conpot.py index a4e4ee0..073d8dc 100644 --- a/decnet/services/conpot.py +++ b/decnet/services/conpot.py @@ -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", diff --git a/decnet/services/docker_api.py b/decnet/services/docker_api.py index 7fa8c7e..4cc8e89 100644 --- a/decnet/services/docker_api.py +++ b/decnet/services/docker_api.py @@ -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 diff --git a/decnet/services/elasticsearch.py b/decnet/services/elasticsearch.py index 3f40179..146cfca 100644 --- a/decnet/services/elasticsearch.py +++ b/decnet/services/elasticsearch.py @@ -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: diff --git a/decnet/services/ftp.py b/decnet/services/ftp.py index 978c902..d034c81 100644 --- a/decnet/services/ftp.py +++ b/decnet/services/ftp.py @@ -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: diff --git a/decnet/services/http.py b/decnet/services/http.py index c99bdf3..28e2d47 100644 --- a/decnet/services/http.py +++ b/decnet/services/http.py @@ -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: diff --git a/decnet/services/imap.py b/decnet/services/imap.py index 4f8198f..cf8d09f 100644 --- a/decnet/services/imap.py +++ b/decnet/services/imap.py @@ -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 diff --git a/decnet/services/k8s.py b/decnet/services/k8s.py index a51ae64..b5b3f24 100644 --- a/decnet/services/k8s.py +++ b/decnet/services/k8s.py @@ -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 diff --git a/decnet/services/ldap.py b/decnet/services/ldap.py index 9641a30..48db9f5 100644 --- a/decnet/services/ldap.py +++ b/decnet/services/ldap.py @@ -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 diff --git a/decnet/services/llmnr.py b/decnet/services/llmnr.py index f23e252..9dd4bc7 100644 --- a/decnet/services/llmnr.py +++ b/decnet/services/llmnr.py @@ -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 diff --git a/decnet/services/mongodb.py b/decnet/services/mongodb.py index d394729..4dcad69 100644 --- a/decnet/services/mongodb.py +++ b/decnet/services/mongodb.py @@ -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 diff --git a/decnet/services/mqtt.py b/decnet/services/mqtt.py index 5864e35..e85e14c 100644 --- a/decnet/services/mqtt.py +++ b/decnet/services/mqtt.py @@ -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 diff --git a/decnet/services/mssql.py b/decnet/services/mssql.py index 9870027..9658325 100644 --- a/decnet/services/mssql.py +++ b/decnet/services/mssql.py @@ -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 diff --git a/decnet/services/mysql.py b/decnet/services/mysql.py index c13b8a4..f8d15da 100644 --- a/decnet/services/mysql.py +++ b/decnet/services/mysql.py @@ -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: diff --git a/decnet/services/pop3.py b/decnet/services/pop3.py index a43ff94..5caba08 100644 --- a/decnet/services/pop3.py +++ b/decnet/services/pop3.py @@ -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 diff --git a/decnet/services/postgres.py b/decnet/services/postgres.py index d68ec51..1dbcfa3 100644 --- a/decnet/services/postgres.py +++ b/decnet/services/postgres.py @@ -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 diff --git a/decnet/services/rdp.py b/decnet/services/rdp.py index 47b676e..7c9ac48 100644 --- a/decnet/services/rdp.py +++ b/decnet/services/rdp.py @@ -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: diff --git a/decnet/services/redis.py b/decnet/services/redis.py index 42b1a2f..263823c 100644 --- a/decnet/services/redis.py +++ b/decnet/services/redis.py @@ -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: diff --git a/decnet/services/registry.py b/decnet/services/registry.py index e9ff2fb..bbfc325 100644 --- a/decnet/services/registry.py +++ b/decnet/services/registry.py @@ -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: diff --git a/decnet/services/sip.py b/decnet/services/sip.py index 3e2a938..0d50f65 100644 --- a/decnet/services/sip.py +++ b/decnet/services/sip.py @@ -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 diff --git a/decnet/services/smb.py b/decnet/services/smb.py index 7710416..da96971 100644 --- a/decnet/services/smb.py +++ b/decnet/services/smb.py @@ -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: diff --git a/decnet/services/smtp.py b/decnet/services/smtp.py index 2a98bbe..3e616b5 100644 --- a/decnet/services/smtp.py +++ b/decnet/services/smtp.py @@ -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: diff --git a/decnet/services/snmp.py b/decnet/services/snmp.py index cc06607..613b426 100644 --- a/decnet/services/snmp.py +++ b/decnet/services/snmp.py @@ -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 diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index cb87ddf..2377d57 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -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 diff --git a/decnet/services/telnet.py b/decnet/services/telnet.py index 96a806b..9395d34 100644 --- a/decnet/services/telnet.py +++ b/decnet/services/telnet.py @@ -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", diff --git a/decnet/services/tftp.py b/decnet/services/tftp.py index becd287..17ddd4c 100644 --- a/decnet/services/tftp.py +++ b/decnet/services/tftp.py @@ -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 diff --git a/decnet/services/vnc.py b/decnet/services/vnc.py index 022aadc..63cfdee 100644 --- a/decnet/services/vnc.py +++ b/decnet/services/vnc.py @@ -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 diff --git a/templates/cowrie/cowrie.cfg.j2 b/templates/cowrie/cowrie.cfg.j2 index 1fd14b9..e442fca 100644 --- a/templates/cowrie/cowrie.cfg.j2 +++ b/templates/cowrie/cowrie.cfg.j2 @@ -1,10 +1,14 @@ [honeypot] hostname = {{ COWRIE_HOSTNAME | default('svr01') }} listen_endpoints = tcp:2222:interface=0.0.0.0 +kernel_version = {{ COWRIE_HONEYPOT_KERNEL_VERSION | default('5.15.0-76-generic') }} +kernel_build_string = {{ COWRIE_HONEYPOT_KERNEL_BUILD_STRING | default('#83-Ubuntu SMP Thu Jun 15 19:16:32 UTC 2023') }} +hardware_platform = {{ COWRIE_HONEYPOT_HARDWARE_PLATFORM | default('x86_64') }} [ssh] enabled = true listen_endpoints = tcp:2222:interface=0.0.0.0 +version = {{ COWRIE_SSH_VERSION | default('SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5') }} {% if COWRIE_LOG_HOST is defined and COWRIE_LOG_HOST %} [output_jsonlog] diff --git a/templates/cowrie/entrypoint.sh b/templates/cowrie/entrypoint.sh index b9dda69..90bd3cb 100644 --- a/templates/cowrie/entrypoint.sh +++ b/templates/cowrie/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -# Render Jinja2 template using the venv's python (has jinja2) +# Render Jinja2 config template /home/cowrie/cowrie-env/bin/python3 - <<'EOF' import os from jinja2 import Template @@ -15,4 +15,19 @@ with open("/home/cowrie/cowrie-env/etc/cowrie.cfg", "w") as f: f.write(rendered) EOF +# Write userdb.txt if custom users were provided +# Format: COWRIE_USERDB_ENTRIES=root:toor,admin:admin123 +if [ -n "${COWRIE_USERDB_ENTRIES}" ]; then + USERDB="/home/cowrie/cowrie-env/etc/userdb.txt" + : > "$USERDB" + IFS=',' read -ra PAIRS <<< "${COWRIE_USERDB_ENTRIES}" + for pair in "${PAIRS[@]}"; do + user="${pair%%:*}" + pass="${pair#*:}" + uid=1000 + [ "$user" = "root" ] && uid=0 + echo "${user}:${uid}:${pass}" >> "$USERDB" + done +fi + exec authbind --deep /home/cowrie/cowrie-env/bin/twistd -n --pidfile= cowrie diff --git a/templates/docker_api/Dockerfile b/templates/docker_api/Dockerfile index 60a2d3d..b0001ed 100644 --- a/templates/docker_api/Dockerfile +++ b/templates/docker_api/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir flask -COPY docker_api_honeypot.py /opt/docker_api_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/docker_api/entrypoint.sh b/templates/docker_api/entrypoint.sh index f41b04d..c830b73 100644 --- a/templates/docker_api/entrypoint.sh +++ b/templates/docker_api/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/docker_api_honeypot.py +exec python3 /opt/server.py diff --git a/templates/docker_api/docker_api_honeypot.py b/templates/docker_api/server.py similarity index 93% rename from templates/docker_api/docker_api_honeypot.py rename to templates/docker_api/server.py index 251bb20..64e5b36 100644 --- a/templates/docker_api/docker_api_honeypot.py +++ b/templates/docker_api/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Docker API honeypot. +Docker APIserver. Serves a fake Docker REST API on port 2375. Responds to common recon endpoints (/version, /info, /containers/json, /images/json) with plausible but fake data. Logs all requests as JSON. @@ -13,7 +13,7 @@ from datetime import datetime, timezone from flask import Flask, request -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "docker-host") +NODE_NAME = os.environ.get("NODE_NAME", "docker-host") LOG_TARGET = os.environ.get("LOG_TARGET", "") app = Flask(__name__) @@ -38,7 +38,7 @@ _INFO = { "MemoryLimit": True, "SwapLimit": True, "KernelMemory": False, - "Name": HONEYPOT_NAME, + "Name": NODE_NAME, "DockerRootDir": "/var/lib/docker", "HttpProxy": "", "HttpsProxy": "", @@ -73,7 +73,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "docker_api", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -127,5 +127,5 @@ def catch_all(path): if __name__ == "__main__": - _log("startup", msg=f"Docker API honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"Docker API server starting as {NODE_NAME}") app.run(host="0.0.0.0", port=2375, debug=False) diff --git a/templates/elasticsearch/Dockerfile b/templates/elasticsearch/Dockerfile index 4beccf7..f8c066c 100644 --- a/templates/elasticsearch/Dockerfile +++ b/templates/elasticsearch/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY elasticsearch_honeypot.py /opt/elasticsearch_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/elasticsearch/entrypoint.sh b/templates/elasticsearch/entrypoint.sh index 14898cc..c830b73 100644 --- a/templates/elasticsearch/entrypoint.sh +++ b/templates/elasticsearch/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/elasticsearch_honeypot.py +exec python3 /opt/server.py diff --git a/templates/elasticsearch/elasticsearch_honeypot.py b/templates/elasticsearch/server.py similarity index 94% rename from templates/elasticsearch/elasticsearch_honeypot.py rename to templates/elasticsearch/server.py index 7dc6c27..47cc620 100644 --- a/templates/elasticsearch/elasticsearch_honeypot.py +++ b/templates/elasticsearch/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Elasticsearch honeypot — presents a convincing ES 7.x HTTP API on port 9200. +Elasticsearch server — presents a convincing ES 7.x HTTP API on port 9200. Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/) as JSON. Designed to attract automated scanners and credential stuffers. """ @@ -11,14 +11,14 @@ import socket from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "esserver") +NODE_NAME = os.environ.get("NODE_NAME", "esserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") _CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA" _NODE_UUID = "dJH7Lm2sRqWvPn0kFiEtBo" _ROOT_RESPONSE = { - "name": HONEYPOT_NAME, + "name": NODE_NAME, "cluster_name": "elasticsearch", "cluster_uuid": _CLUSTER_UUID, "version": { @@ -51,7 +51,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "elasticsearch", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -110,7 +110,7 @@ class ESHandler(BaseHTTPRequestHandler): if "_search" in path or "_bulk" in path: self._send_json(200, {"took": 1, "timed_out": False, "hits": {"total": {"value": 0}, "hits": []}}) else: - self._send_json(200, {"result": "created", "_id": "1", "_index": "honeypot"}) + self._send_json(200, {"result": "created", "_id": "1", "_index": "server"}) def do_PUT(self): src = self.client_address[0] @@ -133,6 +133,6 @@ class ESHandler(BaseHTTPRequestHandler): if __name__ == "__main__": - _log("startup", msg=f"Elasticsearch honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"Elasticsearch server starting as {NODE_NAME}") server = HTTPServer(("0.0.0.0", 9200), ESHandler) server.serve_forever() diff --git a/templates/ftp/Dockerfile b/templates/ftp/Dockerfile index 7c7a48b..d1ccd1c 100644 --- a/templates/ftp/Dockerfile +++ b/templates/ftp/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir twisted jinja2 -COPY ftp_honeypot.py /opt/ftp_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/ftp/entrypoint.sh b/templates/ftp/entrypoint.sh index f280798..c830b73 100644 --- a/templates/ftp/entrypoint.sh +++ b/templates/ftp/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/ftp_honeypot.py +exec python3 /opt/server.py diff --git a/templates/ftp/ftp_honeypot.py b/templates/ftp/server.py similarity index 74% rename from templates/ftp/ftp_honeypot.py rename to templates/ftp/server.py index 664e0e6..2c4dd34 100644 --- a/templates/ftp/ftp_honeypot.py +++ b/templates/ftp/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -FTP honeypot using Twisted's FTP server infrastructure. +FTP server using Twisted's FTP server infrastructure. Accepts any credentials, logs all commands and file requests, forwards events as JSON to LOG_TARGET if set. """ @@ -15,7 +15,7 @@ from twisted.internet import defer, protocol, reactor from twisted.protocols.ftp import FTP, FTPFactory from twisted.python import log as twisted_log -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "ftpserver") +NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -34,7 +34,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "ftp", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -42,22 +42,22 @@ def _log(event_type: str, **kwargs) -> None: _forward(event) -class HoneypotFTP(FTP): +class ServerFTP(FTP): def connectionMade(self): peer = self.transport.getPeer() _log("connection", src_ip=peer.host, src_port=peer.port) super().connectionMade() def ftp_USER(self, username): - self._honeypot_user = username + self._server_user = username _log("user", username=username) return super().ftp_USER(username) def ftp_PASS(self, password): - _log("auth_attempt", username=getattr(self, "_honeypot_user", "?"), password=password) - # Accept everything — we're a honeypot + _log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password) + # Accept everything — we're a server self.state = self.AUTHED - self._user = getattr(self, "_honeypot_user", "anonymous") + self._user = getattr(self, "_server_user", "anonymous") return defer.succeed((230, "Login successful.")) def ftp_RETR(self, path): @@ -71,12 +71,12 @@ class HoneypotFTP(FTP): super().connectionLost(reason) -class HoneypotFTPFactory(FTPFactory): - protocol = HoneypotFTP +class ServerFTPFactory(FTPFactory): + protocol = ServerFTP if __name__ == "__main__": twisted_log.startLogging(sys.stdout) - _log("startup", msg=f"FTP honeypot starting as {HONEYPOT_NAME} on port 21") - reactor.listenTCP(21, HoneypotFTPFactory()) + _log("startup", msg=f"FTP server starting as {NODE_NAME} on port 21") + reactor.listenTCP(21, ServerFTPFactory()) reactor.run() diff --git a/templates/http/Dockerfile b/templates/http/Dockerfile index 6e78745..29c2631 100644 --- a/templates/http/Dockerfile +++ b/templates/http/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir flask jinja2 -COPY http_honeypot.py /opt/http_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/http/entrypoint.sh b/templates/http/entrypoint.sh index 56812b2..c830b73 100644 --- a/templates/http/entrypoint.sh +++ b/templates/http/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/http_honeypot.py +exec python3 /opt/server.py diff --git a/templates/http/http_honeypot.py b/templates/http/http_honeypot.py deleted file mode 100644 index 9d3c636..0000000 --- a/templates/http/http_honeypot.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -""" -HTTP honeypot using Flask. -Accepts all requests, logs every detail (method, path, headers, body), -and responds with convincing but empty pages. Forwards events as JSON -to LOG_TARGET if set. -""" - -import json -import os -import socket -from datetime import datetime, timezone - -from flask import Flask, request - -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "webserver") -LOG_TARGET = os.environ.get("LOG_TARGET", "") - -app = Flask(__name__) - - -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass - - -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "http", - "host": HONEYPOT_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) - - -@app.before_request -def log_request(): - _log( - "request", - method=request.method, - path=request.path, - remote_addr=request.remote_addr, - headers=dict(request.headers), - body=request.get_data(as_text=True)[:512], - ) - - -@app.route("/", defaults={"path": ""}) -@app.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) -def catch_all(path): - return ( - "

403 Forbidden

", - 403, - {"Server": "Apache/2.4.54 (Debian)", "Content-Type": "text/html"}, - ) - - -if __name__ == "__main__": - _log("startup", msg=f"HTTP honeypot starting as {HONEYPOT_NAME}") - app.run(host="0.0.0.0", port=80, debug=False) diff --git a/templates/http/server.py b/templates/http/server.py new file mode 100644 index 0000000..2df155f --- /dev/null +++ b/templates/http/server.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +HTTP service emulator using Flask. +Accepts all requests, logs every detail (method, path, headers, body), +and responds with configurable pages. Forwards events as JSON to LOG_TARGET if set. +""" + +import json +import os +import socket +from datetime import datetime, timezone +from pathlib import Path + +from flask import Flask, request, send_from_directory + +NODE_NAME = os.environ.get("NODE_NAME", "webserver") +LOG_TARGET = os.environ.get("LOG_TARGET", "") +SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") +RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403")) +FAKE_APP = os.environ.get("FAKE_APP", "") +EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}")) +CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "") +FILES_DIR = os.environ.get("FILES_DIR", "") + +_FAKE_APP_BODIES: dict[str, str] = { + "apache_default": ( + "\n" + "Apache2 Debian Default Page\n" + "

Apache2 Debian Default Page

\n" + "

It works!

" + ), + "nginx_default": ( + "Welcome to nginx!\n" + "

Welcome to nginx!

\n" + "

If you see this page, the nginx web server is successfully installed.

\n" + "" + ), + "wordpress": ( + "WordPress › Error\n" + "
\n" + "

Error establishing a database connection

" + ), + "phpmyadmin": ( + "phpMyAdmin\n" + "
\n" + "\n" + "\n" + "
" + ), + "iis_default": ( + "IIS Windows Server\n" + "

IIS Windows Server

\n" + "

Welcome to Internet Information Services

" + ), +} + +app = Flask(__name__) + + +def _forward(event: dict) -> None: + if not LOG_TARGET: + return + try: + host, port = LOG_TARGET.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((json.dumps(event) + "\n").encode()) + except Exception: + pass + + +def _log(event_type: str, **kwargs) -> None: + event = { + "ts": datetime.now(timezone.utc).isoformat(), + "service": "http", + "host": NODE_NAME, + "event": event_type, + **kwargs, + } + print(json.dumps(event), flush=True) + _forward(event) + + +@app.before_request +def log_request(): + _log( + "request", + method=request.method, + path=request.path, + remote_addr=request.remote_addr, + headers=dict(request.headers), + body=request.get_data(as_text=True)[:512], + ) + + +@app.route("/", defaults={"path": ""}) +@app.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) +def catch_all(path): + # Serve static files directory if configured + if FILES_DIR and path: + files_path = Path(FILES_DIR) / path + if files_path.is_file(): + return send_from_directory(FILES_DIR, path) + + # Select response body: custom > fake_app preset > default 403 + if CUSTOM_BODY: + body = CUSTOM_BODY + elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES: + body = _FAKE_APP_BODIES[FAKE_APP] + else: + body = "

403 Forbidden

" + + headers = {"Server": SERVER_HEADER, "Content-Type": "text/html", **EXTRA_HEADERS} + return body, RESPONSE_CODE, headers + + +if __name__ == "__main__": + _log("startup", msg=f"HTTP server starting as {NODE_NAME}") + app.run(host="0.0.0.0", port=80, debug=False) diff --git a/templates/imap/Dockerfile b/templates/imap/Dockerfile index 600870e..78e99dc 100644 --- a/templates/imap/Dockerfile +++ b/templates/imap/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY imap_honeypot.py /opt/imap_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/imap/entrypoint.sh b/templates/imap/entrypoint.sh index ad61213..c830b73 100644 --- a/templates/imap/entrypoint.sh +++ b/templates/imap/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/imap_honeypot.py +exec python3 /opt/server.py diff --git a/templates/imap/imap_honeypot.py b/templates/imap/server.py similarity index 92% rename from templates/imap/imap_honeypot.py rename to templates/imap/server.py index 0f4c814..01948bc 100644 --- a/templates/imap/imap_honeypot.py +++ b/templates/imap/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -IMAP honeypot. +IMAPserver. Presents an IMAP4rev1 banner, captures LOGIN credentials (plaintext and AUTHENTICATE), then returns a NO response. Logs all commands as JSON. """ @@ -11,9 +11,9 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "mailserver") +NODE_NAME = os.environ.get("NODE_NAME", "mailserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") -BANNER = f"* OK [{HONEYPOT_NAME}] IMAP4rev1 Service Ready\r\n" +BANNER = f"* OK [{NODE_NAME}] IMAP4rev1 Service Ready\r\n" def _forward(event: dict) -> None: @@ -31,7 +31,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "imap", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -87,7 +87,7 @@ class IMAPProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"IMAP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"IMAP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(IMAPProtocol, "0.0.0.0", 143) async with server: diff --git a/templates/k8s/k8s_honeypot.py b/templates/k8s/server.py similarity index 93% rename from templates/k8s/k8s_honeypot.py rename to templates/k8s/server.py index db65056..85ee0fa 100644 --- a/templates/k8s/k8s_honeypot.py +++ b/templates/k8s/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Kubernetes API honeypot. +Kubernetes APIserver. Serves a fake K8s REST API on port 6443 (HTTPS-ish, plain HTTP) and 8080. Responds to recon endpoints (/version, /api, /apis, /api/v1/namespaces, /api/v1/pods) with plausible but fake data. Logs all requests as JSON. @@ -13,7 +13,7 @@ from datetime import datetime, timezone from flask import Flask, request -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "k8s-master") +NODE_NAME = os.environ.get("NODE_NAME", "k8s-master") LOG_TARGET = os.environ.get("LOG_TARGET", "") app = Flask(__name__) @@ -33,7 +33,7 @@ _VERSION = { _API_VERSIONS = { "kind": "APIVersions", "versions": ["v1"], - "serverAddressByClientCIDRs": [{"clientCIDR": "0.0.0.0/0", "serverAddress": f"{HONEYPOT_NAME}:6443"}], + "serverAddressByClientCIDRs": [{"clientCIDR": "0.0.0.0/0", "serverAddress": f"{NODE_NAME}:6443"}], } _NAMESPACES = { @@ -80,7 +80,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "k8s", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -138,5 +138,5 @@ def catch_all(path): if __name__ == "__main__": - _log("startup", msg=f"Kubernetes API honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"Kubernetes API server starting as {NODE_NAME}") app.run(host="0.0.0.0", port=6443, debug=False) diff --git a/templates/ldap/Dockerfile b/templates/ldap/Dockerfile index 1d1552e..e71998a 100644 --- a/templates/ldap/Dockerfile +++ b/templates/ldap/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY ldap_honeypot.py /opt/ldap_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/ldap/entrypoint.sh b/templates/ldap/entrypoint.sh index 9b1476c..c830b73 100644 --- a/templates/ldap/entrypoint.sh +++ b/templates/ldap/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/ldap_honeypot.py +exec python3 /opt/server.py diff --git a/templates/ldap/ldap_honeypot.py b/templates/ldap/server.py similarity index 96% rename from templates/ldap/ldap_honeypot.py rename to templates/ldap/server.py index 77a8da2..9999689 100644 --- a/templates/ldap/ldap_honeypot.py +++ b/templates/ldap/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -LDAP honeypot. +LDAPserver. Parses BER-encoded BindRequest messages, logs DN and password, returns an invalidCredentials error. Logs all interactions as JSON. """ @@ -11,7 +11,7 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "ldapserver") +NODE_NAME = os.environ.get("NODE_NAME", "ldapserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -30,7 +30,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "ldap", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -154,7 +154,7 @@ class LDAPProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"LDAP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"LDAP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(LDAPProtocol, "0.0.0.0", 389) async with server: diff --git a/templates/llmnr/Dockerfile b/templates/llmnr/Dockerfile index 785e135..bb11ed6 100644 --- a/templates/llmnr/Dockerfile +++ b/templates/llmnr/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY llmnr_honeypot.py /opt/llmnr_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/llmnr/entrypoint.sh b/templates/llmnr/entrypoint.sh index 0c6b6b6..c830b73 100644 --- a/templates/llmnr/entrypoint.sh +++ b/templates/llmnr/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/llmnr_honeypot.py +exec python3 /opt/server.py diff --git a/templates/llmnr/llmnr_honeypot.py b/templates/llmnr/server.py similarity index 95% rename from templates/llmnr/llmnr_honeypot.py rename to templates/llmnr/server.py index fe4f908..8a89480 100644 --- a/templates/llmnr/llmnr_honeypot.py +++ b/templates/llmnr/server.py @@ -13,7 +13,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "lan-host") +NODE_NAME = os.environ.get("NODE_NAME", "lan-host") LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -32,7 +32,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "llmnr", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -104,7 +104,7 @@ class LLMNRProtocol(asyncio.DatagramProtocol): async def main(): - _log("startup", msg=f"LLMNR/mDNS honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"LLMNR/mDNS server starting as {NODE_NAME}") loop = asyncio.get_running_loop() # LLMNR: UDP 5355 diff --git a/templates/mongodb/Dockerfile b/templates/mongodb/Dockerfile index f60b049..b54cd3e 100644 --- a/templates/mongodb/Dockerfile +++ b/templates/mongodb/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY mongodb_honeypot.py /opt/mongodb_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mongodb/entrypoint.sh b/templates/mongodb/entrypoint.sh index 870d30c..c830b73 100644 --- a/templates/mongodb/entrypoint.sh +++ b/templates/mongodb/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/mongodb_honeypot.py +exec python3 /opt/server.py diff --git a/templates/mongodb/mongodb_honeypot.py b/templates/mongodb/server.py similarity index 95% rename from templates/mongodb/mongodb_honeypot.py rename to templates/mongodb/server.py index 9eea9a3..2597f1a 100644 --- a/templates/mongodb/mongodb_honeypot.py +++ b/templates/mongodb/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -MongoDB honeypot. +MongoDBserver. Implements the MongoDB wire protocol OP_MSG/OP_QUERY handshake. Responds to isMaster/hello, listDatabases, and authenticate commands. Logs all received messages as JSON. @@ -13,7 +13,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "mongodb") +NODE_NAME = os.environ.get("NODE_NAME", "mongodb") LOG_TARGET = os.environ.get("LOG_TARGET", "") # Minimal BSON helpers @@ -64,7 +64,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "mongodb", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -115,7 +115,7 @@ class MongoDBProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"MongoDB honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"MongoDB server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(MongoDBProtocol, "0.0.0.0", 27017) async with server: diff --git a/templates/mqtt/Dockerfile b/templates/mqtt/Dockerfile index 3c902bb..aff41ad 100644 --- a/templates/mqtt/Dockerfile +++ b/templates/mqtt/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY mqtt_honeypot.py /opt/mqtt_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mqtt/entrypoint.sh b/templates/mqtt/entrypoint.sh index 7944631..c830b73 100644 --- a/templates/mqtt/entrypoint.sh +++ b/templates/mqtt/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/mqtt_honeypot.py +exec python3 /opt/server.py diff --git a/templates/mqtt/mqtt_honeypot.py b/templates/mqtt/server.py similarity index 95% rename from templates/mqtt/mqtt_honeypot.py rename to templates/mqtt/server.py index 41099e1..aa40d70 100644 --- a/templates/mqtt/mqtt_honeypot.py +++ b/templates/mqtt/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -MQTT honeypot (port 1883). +MQTT server (port 1883). Parses MQTT CONNECT packets, extracts client_id, username, and password, then returns CONNACK with return code 5 (not authorized). Logs all interactions as JSON. @@ -13,7 +13,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "mqtt-broker") +NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") LOG_TARGET = os.environ.get("LOG_TARGET", "") # CONNACK: packet type 0x20, remaining length 2, session_present=0, return_code=5 @@ -35,7 +35,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "mqtt", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -137,7 +137,7 @@ class MQTTProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"MQTT honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"MQTT server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(MQTTProtocol, "0.0.0.0", 1883) async with server: diff --git a/templates/mssql/Dockerfile b/templates/mssql/Dockerfile index c4fc81a..a1ad71d 100644 --- a/templates/mssql/Dockerfile +++ b/templates/mssql/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY mssql_honeypot.py /opt/mssql_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mssql/entrypoint.sh b/templates/mssql/entrypoint.sh index f41c533..c830b73 100644 --- a/templates/mssql/entrypoint.sh +++ b/templates/mssql/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/mssql_honeypot.py +exec python3 /opt/server.py diff --git a/templates/mssql/mssql_honeypot.py b/templates/mssql/server.py similarity index 96% rename from templates/mssql/mssql_honeypot.py rename to templates/mssql/server.py index a6e9b22..80e8f82 100644 --- a/templates/mssql/mssql_honeypot.py +++ b/templates/mssql/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -MSSQL (TDS) honeypot. +MSSQL (TDS)server. Reads TDS pre-login and login7 packets, extracts username, responds with a login failed error. Logs auth attempts as JSON. """ @@ -12,7 +12,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "dbserver") +NODE_NAME = os.environ.get("NODE_NAME", "dbserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") # Minimal TDS pre-login response @@ -54,7 +54,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "mssql", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -137,7 +137,7 @@ class MSSQLProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"MSSQL honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"MSSQL server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(MSSQLProtocol, "0.0.0.0", 1433) async with server: diff --git a/templates/mysql/Dockerfile b/templates/mysql/Dockerfile index c271b71..730a74a 100644 --- a/templates/mysql/Dockerfile +++ b/templates/mysql/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY mysql_honeypot.py /opt/mysql_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mysql/entrypoint.sh b/templates/mysql/entrypoint.sh index a7e5e94..c830b73 100644 --- a/templates/mysql/entrypoint.sh +++ b/templates/mysql/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/mysql_honeypot.py +exec python3 /opt/server.py diff --git a/templates/mysql/mysql_honeypot.py b/templates/mysql/server.py similarity index 74% rename from templates/mysql/mysql_honeypot.py rename to templates/mysql/server.py index 5b9168e..796bb5d 100644 --- a/templates/mysql/mysql_honeypot.py +++ b/templates/mysql/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -MySQL honeypot. +MySQLserver. Sends a realistic MySQL 5.7 server handshake, reads the client login packet, extracts username, then closes with Access Denied. Logs auth attempts as JSON. @@ -13,24 +13,25 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "dbserver") -LOG_TARGET = os.environ.get("LOG_TARGET", "") +NODE_NAME = os.environ.get("NODE_NAME", "dbserver") +LOG_TARGET = os.environ.get("LOG_TARGET", "") +_MYSQL_VER = os.environ.get("MYSQL_VERSION", "5.7.38-log") -# Minimal MySQL 5.7 server greeting (protocol v10) +# Minimal MySQL server greeting (protocol v10) — version string is configurable _GREETING = ( b"\x0a" # protocol version 10 - b"5.7.38-honeypot\x00" # server version + NUL - b"\x01\x00\x00\x00" # connection id = 1 - b"\x70\x76\x21\x6d\x61\x67\x69\x63" # auth-plugin-data part 1 - b"\x00" # filler - b"\xff\xf7" # capability flags low - b"\x21" # charset utf8 - b"\x02\x00" # status flags - b"\xff\x81" # capability flags high - b"\x15" # auth plugin data length - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" # reserved (10 bytes) - b"\x21\x4f\x7d\x25\x3e\x55\x4d\x7c\x67\x75\x5e\x31\x00" # auth part 2 - b"mysql_native_password\x00" # auth plugin name + + _MYSQL_VER.encode() + b"\x00" # server version + NUL + + b"\x01\x00\x00\x00" # connection id = 1 + + b"\x70\x76\x21\x6d\x61\x67\x69\x63" # auth-plugin-data part 1 + + b"\x00" # filler + + b"\xff\xf7" # capability flags low + + b"\x21" # charset utf8 + + b"\x02\x00" # status flags + + b"\xff\x81" # capability flags high + + b"\x15" # auth plugin data length + + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" # reserved (10 bytes) + + b"\x21\x4f\x7d\x25\x3e\x55\x4d\x7c\x67\x75\x5e\x31\x00" # auth part 2 + + b"mysql_native_password\x00" # auth plugin name ) @@ -54,7 +55,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "mysql", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -110,7 +111,7 @@ class MySQLProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"MySQL honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"MySQL server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(MySQLProtocol, "0.0.0.0", 3306) async with server: diff --git a/templates/pop3/pop3_honeypot.py b/templates/pop3/server.py similarity index 92% rename from templates/pop3/pop3_honeypot.py rename to templates/pop3/server.py index 4726a8d..26ada84 100644 --- a/templates/pop3/pop3_honeypot.py +++ b/templates/pop3/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -POP3 honeypot. +POP3server. Presents a convincing POP3 banner, collects USER/PASS credentials, then stalls with a generic error. Logs every interaction as JSON and forwards to LOG_TARGET if set. @@ -12,9 +12,9 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "mailserver") +NODE_NAME = os.environ.get("NODE_NAME", "mailserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") -BANNER = f"+OK {HONEYPOT_NAME} POP3 server ready\r\n" +BANNER = f"+OK {NODE_NAME} POP3 server ready\r\n" def _forward(event: dict) -> None: @@ -32,7 +32,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "pop3", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -83,7 +83,7 @@ class POP3Protocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"POP3 honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"POP3 server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(POP3Protocol, "0.0.0.0", 110) async with server: diff --git a/templates/postgres/Dockerfile b/templates/postgres/Dockerfile index 9b64494..1e46a82 100644 --- a/templates/postgres/Dockerfile +++ b/templates/postgres/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY postgres_honeypot.py /opt/postgres_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/postgres/entrypoint.sh b/templates/postgres/entrypoint.sh index 57062af..c830b73 100644 --- a/templates/postgres/entrypoint.sh +++ b/templates/postgres/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/postgres_honeypot.py +exec python3 /opt/server.py diff --git a/templates/postgres/postgres_honeypot.py b/templates/postgres/server.py similarity index 95% rename from templates/postgres/postgres_honeypot.py rename to templates/postgres/server.py index 710b306..3fb1611 100644 --- a/templates/postgres/postgres_honeypot.py +++ b/templates/postgres/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -PostgreSQL honeypot. +PostgreSQLserver. Reads the startup message, extracts username and database, responds with an AuthenticationMD5Password challenge, logs the hash sent back, then returns an error. Logs all interactions as JSON. @@ -13,7 +13,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "pgserver") +NODE_NAME = os.environ.get("NODE_NAME", "pgserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") SALT = b"\xde\xad\xbe\xef" @@ -40,7 +40,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "postgres", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -118,7 +118,7 @@ class PostgresProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"PostgreSQL honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"PostgreSQL server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(PostgresProtocol, "0.0.0.0", 5432) async with server: diff --git a/templates/rdp/Dockerfile b/templates/rdp/Dockerfile index 7e6ebb5..df2517d 100644 --- a/templates/rdp/Dockerfile +++ b/templates/rdp/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir twisted jinja2 -COPY rdp_honeypot.py /opt/rdp_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/rdp/entrypoint.sh b/templates/rdp/entrypoint.sh index 332ef84..c830b73 100644 --- a/templates/rdp/entrypoint.sh +++ b/templates/rdp/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/rdp_honeypot.py +exec python3 /opt/server.py diff --git a/templates/rdp/rdp_honeypot.py b/templates/rdp/server.py similarity index 82% rename from templates/rdp/rdp_honeypot.py rename to templates/rdp/server.py index 08bbd64..350042f 100644 --- a/templates/rdp/rdp_honeypot.py +++ b/templates/rdp/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Minimal RDP honeypot using Twisted. +Minimal RDP server using Twisted. Listens on port 3389, logs connection attempts and any credentials sent in the initial RDP negotiation request. Forwards events as JSON to LOG_TARGET if set. @@ -15,7 +15,7 @@ from datetime import datetime, timezone from twisted.internet import protocol, reactor from twisted.python import log as twisted_log -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "WORKSTATION") +NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -34,7 +34,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "rdp", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -42,7 +42,7 @@ def _log(event_type: str, **kwargs) -> None: _forward(event) -class RDPHoneypotProtocol(protocol.Protocol): +class RDPServerProtocol(protocol.Protocol): def connectionMade(self): peer = self.transport.getPeer() _log("connection", src_ip=peer.host, src_port=peer.port) @@ -61,12 +61,12 @@ class RDPHoneypotProtocol(protocol.Protocol): _log("disconnect", src_ip=peer.host, src_port=peer.port) -class RDPHoneypotFactory(protocol.ServerFactory): - protocol = RDPHoneypotProtocol +class RDPServerFactory(protocol.ServerFactory): + protocol = RDPServerProtocol if __name__ == "__main__": twisted_log.startLogging(sys.stdout) - _log("startup", msg=f"RDP honeypot starting as {HONEYPOT_NAME} on port 3389") - reactor.listenTCP(3389, RDPHoneypotFactory()) + _log("startup", msg=f"RDP server starting as {NODE_NAME} on port 3389") + reactor.listenTCP(3389, RDPServerFactory()) reactor.run() diff --git a/templates/redis/Dockerfile b/templates/redis/Dockerfile index e5a0048..cbf88f0 100644 --- a/templates/redis/Dockerfile +++ b/templates/redis/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY redis_honeypot.py /opt/redis_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/redis/entrypoint.sh b/templates/redis/entrypoint.sh index 5a3177d..c830b73 100644 --- a/templates/redis/entrypoint.sh +++ b/templates/redis/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/redis_honeypot.py +exec python3 /opt/server.py diff --git a/templates/redis/redis_honeypot.py b/templates/redis/server.py similarity index 88% rename from templates/redis/redis_honeypot.py rename to templates/redis/server.py index 57306c6..6e5584a 100644 --- a/templates/redis/redis_honeypot.py +++ b/templates/redis/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Redis honeypot. +Redisserver. Implements enough of the RESP protocol to respond to AUTH, INFO, CONFIG GET, KEYS, and arbitrary commands. Logs every command and argument as JSON. """ @@ -11,19 +11,22 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "cache-server") -LOG_TARGET = os.environ.get("LOG_TARGET", "") +NODE_NAME = os.environ.get("NODE_NAME", "cache-server") +LOG_TARGET = os.environ.get("LOG_TARGET", "") +_REDIS_VER = os.environ.get("REDIS_VERSION", "7.0.12") +_REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0") -_INFO = f"""# Server -redis_version:7.0.12 -redis_mode:standalone -os:Linux 5.15.0 -arch_bits:64 -tcp_port:6379 -uptime_in_seconds:864000 -connected_clients:1 -# Keyspace -""".encode() +_INFO = ( + f"# Server\n" + f"redis_version:{_REDIS_VER}\n" + f"redis_mode:standalone\n" + f"os:{_REDIS_OS}\n" + f"arch_bits:64\n" + f"tcp_port:6379\n" + f"uptime_in_seconds:864000\n" + f"connected_clients:1\n" + f"# Keyspace\n" +).encode() def _forward(event: dict) -> None: @@ -41,7 +44,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "redis", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -158,7 +161,7 @@ class RedisProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"Redis honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"Redis server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(RedisProtocol, "0.0.0.0", 6379) async with server: diff --git a/templates/sip/Dockerfile b/templates/sip/Dockerfile index 225380e..cd0d02c 100644 --- a/templates/sip/Dockerfile +++ b/templates/sip/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY sip_honeypot.py /opt/sip_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/sip/entrypoint.sh b/templates/sip/entrypoint.sh index 498da1f..c830b73 100644 --- a/templates/sip/entrypoint.sh +++ b/templates/sip/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/sip_honeypot.py +exec python3 /opt/server.py diff --git a/templates/sip/sip_honeypot.py b/templates/sip/server.py similarity index 94% rename from templates/sip/sip_honeypot.py rename to templates/sip/server.py index c003c95..3b04059 100644 --- a/templates/sip/sip_honeypot.py +++ b/templates/sip/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -SIP honeypot (UDP + TCP port 5060). +SIP server (UDP + TCP port 5060). Parses SIP REGISTER and INVITE messages, logs credentials from the Authorization header and call metadata, then responds with 401 Unauthorized. """ @@ -12,7 +12,7 @@ import re import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "pbx") +NODE_NAME = os.environ.get("NODE_NAME", "pbx") LOG_TARGET = os.environ.get("LOG_TARGET", "") _401 = ( @@ -42,7 +42,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "sip", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -92,7 +92,7 @@ def _handle_message(data: bytes, src_addr) -> bytes | None: to=headers.get("to", ""), call_id=headers.get("call-id", ""), cseq=headers.get("cseq", ""), - host=HONEYPOT_NAME, + host=NODE_NAME, ) return response.encode() return None @@ -134,7 +134,7 @@ class SIPTCPProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"SIP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"SIP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() udp_transport, _ = await loop.create_datagram_endpoint( SIPUDPProtocol, local_addr=("0.0.0.0", 5060) diff --git a/templates/smb/Dockerfile b/templates/smb/Dockerfile index 79c62ee..5d98fe6 100644 --- a/templates/smb/Dockerfile +++ b/templates/smb/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir impacket jinja2 -COPY smb_honeypot.py /opt/smb_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/smb/entrypoint.sh b/templates/smb/entrypoint.sh index bc03832..89e4829 100644 --- a/templates/smb/entrypoint.sh +++ b/templates/smb/entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/bash set -e mkdir -p /tmp/smb_share -exec python3 /opt/smb_honeypot.py +exec python3 /opt/server.py diff --git a/templates/smb/smb_honeypot.py b/templates/smb/server.py similarity index 84% rename from templates/smb/smb_honeypot.py rename to templates/smb/server.py index d82ebd4..7fdbef2 100644 --- a/templates/smb/smb_honeypot.py +++ b/templates/smb/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Minimal SMB honeypot using Impacket's SimpleSMBServer. +Minimal SMB server using Impacket's SimpleSMBServer. Logs all connection attempts, optionally forwarding them as JSON to LOG_TARGET. """ @@ -11,7 +11,7 @@ from datetime import datetime, timezone from impacket import smbserver -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "WORKSTATION") +NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") LOG_TARGET = os.environ.get("LOG_TARGET", "") @@ -30,7 +30,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "smb", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -39,7 +39,7 @@ def _log(event_type: str, **kwargs) -> None: if __name__ == "__main__": - _log("startup", msg=f"SMB honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"SMB server starting as {NODE_NAME}") os.makedirs("/tmp/smb_share", exist_ok=True) server = smbserver.SimpleSMBServer(listenAddress="0.0.0.0", listenPort=445) diff --git a/templates/smtp/Dockerfile b/templates/smtp/Dockerfile index 416ac07..581520f 100644 --- a/templates/smtp/Dockerfile +++ b/templates/smtp/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY smtp_honeypot.py /opt/smtp_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/smtp/entrypoint.sh b/templates/smtp/entrypoint.sh index e113316..c830b73 100644 --- a/templates/smtp/entrypoint.sh +++ b/templates/smtp/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/smtp_honeypot.py +exec python3 /opt/server.py diff --git a/templates/smtp/smtp_honeypot.py b/templates/smtp/server.py similarity index 88% rename from templates/smtp/smtp_honeypot.py rename to templates/smtp/server.py index cb611a6..ac2659c 100644 --- a/templates/smtp/smtp_honeypot.py +++ b/templates/smtp/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -SMTP honeypot — emulates a realistic ESMTP server (Postfix-style). +SMTP server — emulates a realistic ESMTP server (Postfix-style). Logs EHLO/AUTH/MAIL FROM/RCPT TO attempts as JSON, then denies auth. """ @@ -10,8 +10,10 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "mailserver") -LOG_TARGET = os.environ.get("LOG_TARGET", "") +NODE_NAME = os.environ.get("NODE_NAME", "mailserver") +LOG_TARGET = os.environ.get("LOG_TARGET", "") +_SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)") +_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME) def _forward(event: dict) -> None: @@ -29,7 +31,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "smtp", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -47,7 +49,7 @@ class SMTPProtocol(asyncio.Protocol): self._transport = transport self._peer = transport.get_extra_info("peername", ("?", 0)) _log("connect", src=self._peer[0], src_port=self._peer[1]) - transport.write(f"220 {HONEYPOT_NAME} ESMTP Postfix (Debian/GNU)\r\n".encode()) + transport.write(f"{_SMTP_BANNER}\r\n".encode()) def data_received(self, data): self._buf += data @@ -62,7 +64,7 @@ class SMTPProtocol(asyncio.Protocol): domain = line.split(None, 1)[1] if " " in line else "" _log("ehlo", src=self._peer[0], domain=domain) self._transport.write( - f"250-{HONEYPOT_NAME}\r\n" + f"250-{_SMTP_MTA}\r\n" f"250-PIPELINING\r\n" f"250-SIZE 10240000\r\n" f"250-VRFY\r\n" @@ -106,7 +108,7 @@ class SMTPProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"SMTP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"SMTP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(SMTPProtocol, "0.0.0.0", 25) async with server: diff --git a/templates/snmp/Dockerfile b/templates/snmp/Dockerfile index 73d3fa6..6499466 100644 --- a/templates/snmp/Dockerfile +++ b/templates/snmp/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY snmp_honeypot.py /opt/snmp_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/snmp/entrypoint.sh b/templates/snmp/entrypoint.sh index e7c50a1..c830b73 100644 --- a/templates/snmp/entrypoint.sh +++ b/templates/snmp/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/snmp_honeypot.py +exec python3 /opt/server.py diff --git a/templates/snmp/snmp_honeypot.py b/templates/snmp/server.py similarity index 94% rename from templates/snmp/snmp_honeypot.py rename to templates/snmp/server.py index 5c82da5..1d09bf4 100644 --- a/templates/snmp/snmp_honeypot.py +++ b/templates/snmp/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -SNMP honeypot (UDP 161). +SNMP server (UDP 161). Parses SNMPv1/v2c GetRequest PDUs, logs the community string and OID list, then responds with a GetResponse containing plausible system OID values. Logs all requests as JSON. @@ -13,16 +13,16 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "switch") +NODE_NAME = os.environ.get("NODE_NAME", "switch") LOG_TARGET = os.environ.get("LOG_TARGET", "") # OID value map — fake but plausible _OID_VALUES = { - "1.3.6.1.2.1.1.1.0": f"Linux {HONEYPOT_NAME} 5.15.0-76-generic #83-Ubuntu SMP x86_64", + "1.3.6.1.2.1.1.1.0": f"Linux {NODE_NAME} 5.15.0-76-generic #83-Ubuntu SMP x86_64", "1.3.6.1.2.1.1.2.0": "1.3.6.1.4.1.8072.3.2.10", "1.3.6.1.2.1.1.3.0": "12345678", # sysUpTime "1.3.6.1.2.1.1.4.0": "admin@localhost", - "1.3.6.1.2.1.1.5.0": HONEYPOT_NAME, + "1.3.6.1.2.1.1.5.0": NODE_NAME, "1.3.6.1.2.1.1.6.0": "Server Room", "1.3.6.1.2.1.1.7.0": "72", } @@ -43,7 +43,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "snmp", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -180,7 +180,7 @@ class SNMPProtocol(asyncio.DatagramProtocol): async def main(): - _log("startup", msg=f"SNMP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"SNMP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() transport, _ = await loop.create_datagram_endpoint( SNMPProtocol, local_addr=("0.0.0.0", 161) diff --git a/templates/tftp/Dockerfile b/templates/tftp/Dockerfile index f1330ea..f131a82 100644 --- a/templates/tftp/Dockerfile +++ b/templates/tftp/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY tftp_honeypot.py /opt/tftp_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/tftp/entrypoint.sh b/templates/tftp/entrypoint.sh index 7f24fa6..c830b73 100644 --- a/templates/tftp/entrypoint.sh +++ b/templates/tftp/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/tftp_honeypot.py +exec python3 /opt/server.py diff --git a/templates/tftp/tftp_honeypot.py b/templates/tftp/server.py similarity index 93% rename from templates/tftp/tftp_honeypot.py rename to templates/tftp/server.py index 9bc13b2..b9f4f6e 100644 --- a/templates/tftp/tftp_honeypot.py +++ b/templates/tftp/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -TFTP honeypot (UDP 69). +TFTP server (UDP 69). Parses RRQ (read) and WRQ (write) requests, logs filename and transfer mode, then responds with an error packet. Logs all requests as JSON. """ @@ -12,7 +12,7 @@ import socket import struct from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "tftpserver") +NODE_NAME = os.environ.get("NODE_NAME", "tftpserver") LOG_TARGET = os.environ.get("LOG_TARGET", "") # TFTP opcodes @@ -40,7 +40,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "tftp", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -81,7 +81,7 @@ class TFTPProtocol(asyncio.DatagramProtocol): async def main(): - _log("startup", msg=f"TFTP honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"TFTP server starting as {NODE_NAME}") loop = asyncio.get_running_loop() transport, _ = await loop.create_datagram_endpoint( TFTPProtocol, local_addr=("0.0.0.0", 69) diff --git a/templates/vnc/Dockerfile b/templates/vnc/Dockerfile index 905e74a..9cb1fe6 100644 --- a/templates/vnc/Dockerfile +++ b/templates/vnc/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY vnc_honeypot.py /opt/vnc_honeypot.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/vnc/entrypoint.sh b/templates/vnc/entrypoint.sh index fcf5c10..c830b73 100644 --- a/templates/vnc/entrypoint.sh +++ b/templates/vnc/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/vnc_honeypot.py +exec python3 /opt/server.py diff --git a/templates/vnc/vnc_honeypot.py b/templates/vnc/server.py similarity index 94% rename from templates/vnc/vnc_honeypot.py rename to templates/vnc/server.py index 635b167..49ae10b 100644 --- a/templates/vnc/vnc_honeypot.py +++ b/templates/vnc/server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -VNC (RFB) honeypot. +VNC (RFB)server. Performs the RFB 3.8 handshake, offers VNC authentication, captures the 24-byte DES-encrypted challenge response, then rejects with "Authentication failed". Logs the raw response for offline cracking. @@ -12,7 +12,7 @@ import os import socket from datetime import datetime, timezone -HONEYPOT_NAME = os.environ.get("HONEYPOT_NAME", "desktop") +NODE_NAME = os.environ.get("NODE_NAME", "desktop") LOG_TARGET = os.environ.get("LOG_TARGET", "") # RFB challenge — fixed so captured responses are reproducible @@ -34,7 +34,7 @@ def _log(event_type: str, **kwargs) -> None: event = { "ts": datetime.now(timezone.utc).isoformat(), "service": "vnc", - "host": HONEYPOT_NAME, + "host": NODE_NAME, "event": event_type, **kwargs, } @@ -100,7 +100,7 @@ class VNCProtocol(asyncio.Protocol): async def main(): - _log("startup", msg=f"VNC honeypot starting as {HONEYPOT_NAME}") + _log("startup", msg=f"VNC server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(VNCProtocol, "0.0.0.0", 5900) async with server: diff --git a/tests/test_composer.py b/tests/test_composer.py index d707a6a..308e93f 100644 --- a/tests/test_composer.py +++ b/tests/test_composer.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_ini_loader.py b/tests/test_ini_loader.py new file mode 100644 index 0000000..177adc5 --- /dev/null +++ b/tests/test_ini_loader.py @@ -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 == [] diff --git a/tests/test_services.py b/tests/test_services.py index 86902ad..0edb85b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -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": "hi"}).get("environment", {}) + assert env.get("CUSTOM_BODY") == "hi" + + +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