diff --git a/decnet/templates/elasticsearch/server.py b/decnet/templates/elasticsearch/server.py index e65ee4cc..c4fd2507 100644 --- a/decnet/templates/elasticsearch/server.py +++ b/decnet/templates/elasticsearch/server.py @@ -5,32 +5,73 @@ Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/) as JSON. Designed to attract automated scanners and credential stuffers. """ +import base64 import json import os from http.server import BaseHTTPRequestHandler, HTTPServer + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "esserver") SERVICE_NAME = "elasticsearch" LOG_TARGET = os.environ.get("LOG_TARGET", "") -_CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA" -_NODE_UUID = "dJH7Lm2sRqWvPn0kFiEtBo" +# Real ES cluster/node UUIDs are 22-char base64 (16 random bytes, +# URL-safe, unpadded). Generate deterministically per instance. +def _es_uuid(namespace: str) -> str: + raw = _seed.random_bytes(16, namespace) + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +_CLUSTER_UUID = _es_uuid("es-cluster") +_NODE_UUID = _es_uuid("es-node") +_CLUSTER_NAME = os.environ.get("ES_CLUSTER_NAME") or _seed.pick([ + "elasticsearch", "logs", "search-prod", "metrics", "siem-cluster", + "docker-cluster", +]) + +# Realistic (version, build_hash, build_date, lucene_version) tuples taken +# from real ES release metadata. Build-hashes change per release; pairing +# them correctly is what makes the version check survive a real client +# reading /_nodes and comparing against its known-versions table. +_ES_RELEASES = [ + ("7.17.9", "ef48222227ee6b9e70e502f0f0daa52435ee634d", "2023-01-31T05:34:43.305517834Z", "8.11.1"), + ("7.17.14", "774e3bfa4d52e2834e4d9fdbb4b462fa1ba1cc5a", "2023-10-05T12:16:58.531639647Z", "8.11.1"), + ("7.17.18", "8682172c2130b9a411b1bd1ff37c2f4f15f04c7b", "2024-02-02T16:43:31.000Z", "8.11.1"), + ("8.10.4", "b4a62ac808e886ff032700c391f45f1408b2538c", "2023-10-11T22:04:35.506990650Z", "9.7.0"), + ("8.11.4", "49b9bd5ec73c11d7b49dbd6ffc70b9ea2cdb67d0", "2023-12-19T16:57:03.000Z", "9.8.0"), + ("8.12.2", "48a287ab9497e852de30327444b0809e55d46466", "2024-02-15T15:25:20.000Z", "9.9.2"), + ("8.13.4", "da95df118650b55a500dcc181889ac35c6d8da7c", "2024-05-07T15:39:32.000Z", "9.10.0"), +] +_ES_VERSION, _ES_BUILD_HASH, _ES_BUILD_DATE, _ES_LUCENE = _seed.pick(_ES_RELEASES) + +# Wire-compat rules in ES are hard-coded per major: pick the right ones. +if _ES_VERSION.startswith("8."): + _MIN_WIRE = "7.17.0" + _MIN_INDEX = "7.0.0" +else: + _MIN_WIRE = "6.8.0" + _MIN_INDEX = "6.0.0-beta1" + +# Per-instance cluster size — shapes /_cat/nodes + /_cluster/health output. +_CLUSTER_NODES = _seed.rng.choice([1, 1, 3, 3, 3, 5, 5, 7]) + _ROOT_RESPONSE = { "name": NODE_NAME, - "cluster_name": "elasticsearch", + "cluster_name": _CLUSTER_NAME, "cluster_uuid": _CLUSTER_UUID, "version": { - "number": "7.17.9", + "number": _ES_VERSION, "build_flavor": "default", "build_type": "docker", - "build_hash": "ef48222227ee6b9e70e502f0f0daa52435ee634d", - "build_date": "2023-01-31T05:34:43.305517834Z", + "build_hash": _ES_BUILD_HASH, + "build_date": _ES_BUILD_DATE, "build_snapshot": False, - "lucene_version": "8.11.1", - "minimum_wire_compatibility_version": "6.8.0", - "minimum_index_compatibility_version": "6.0.0-beta1", + "lucene_version": _ES_LUCENE, + "minimum_wire_compatibility_version": _MIN_WIRE, + "minimum_index_compatibility_version": _MIN_INDEX, }, "tagline": "You Know, for Search", } @@ -73,11 +114,28 @@ class ESHandler(BaseHTTPRequestHandler): self._send_json(200, []) elif path.startswith("/_cluster/"): _log("cluster_recon", src=src, method="GET", path=self.path) - self._send_json(200, {"cluster_name": "elasticsearch", "status": "green", - "number_of_nodes": 3, "number_of_data_nodes": 3}) + self._send_json(200, { + "cluster_name": _CLUSTER_NAME, + "cluster_uuid": _CLUSTER_UUID, + "status": _seed.pick(["green", "green", "green", "yellow"]), + "timed_out": False, + "number_of_nodes": _CLUSTER_NODES, + "number_of_data_nodes": _CLUSTER_NODES, + "active_primary_shards": _seed.rng.randint(5, 180), + "active_shards": _seed.rng.randint(10, 360), + "relocating_shards": 0, + "initializing_shards": 0, + "unassigned_shards": 0, + "active_shards_percent_as_number": 100.0, + }) elif path.startswith("/_nodes"): _log("nodes_recon", src=src, method="GET", path=self.path) - self._send_json(200, {"_nodes": {"total": 3, "successful": 3, "failed": 0}, "nodes": {}}) + self._send_json(200, { + "_nodes": {"total": _CLUSTER_NODES, "successful": _CLUSTER_NODES, "failed": 0}, + "cluster_name": _CLUSTER_NAME, + "nodes": {_NODE_UUID: {"name": NODE_NAME, "version": _ES_VERSION, + "build_hash": _ES_BUILD_HASH}}, + }) elif path.startswith("/_security/") or path.startswith("/_xpack/"): _log("security_probe", src=src, method="GET", path=self.path) self._send_json(200, {"enabled": True, "available": True}) diff --git a/decnet/templates/ftp/server.py b/decnet/templates/ftp/server.py index be6136f0..0b6d083f 100644 --- a/decnet/templates/ftp/server.py +++ b/decnet/templates/ftp/server.py @@ -12,30 +12,82 @@ from twisted.internet import defer, reactor from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell from twisted.python.filepath import FilePath from twisted.python import log as twisted_log + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") SERVICE_NAME = "ftp" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "21")) -BANNER = os.environ.get("FTP_BANNER", "220 (vsFTPd 3.0.3)") + +# Per-instance daemon identity. Fleet-wide "vsFTPd 3.0.3" is an instant +# fingerprint of an unmaintained honeypot — real shops run a mix. +_FTP_BANNER_CHOICES = [ + "220 (vsFTPd 3.0.3)", + "220 (vsFTPd 3.0.5)", + "220 ProFTPD 1.3.7a Server ready.", + "220 ProFTPD 1.3.6 Server ready.", + "220 Pure-FTPd Service ready.", +] +BANNER = os.environ.get("FTP_BANNER") or _seed.pick(_FTP_BANNER_CHOICES) + +# Accept approximately this fraction of logins. Real anon-accessible +# servers succeed often; credential-harvesting scanners hitting every +# possible user/pass pair should still see plausible failures. +_LOGIN_SUCCESS_RATE = float(os.environ.get("FTP_LOGIN_SUCCESS_RATE", "0.9")) + +# Optional override — if set to "never", ALL logins fail (realistic for a +# server with anonymous disabled). Handy for producing server diversity +# across the fleet. +_LOGIN_MODE = os.environ.get("FTP_LOGIN_MODE", "").strip().lower() + def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) write_syslog_file(line) forward_syslog(line, LOG_TARGET) + def _setup_bait_fs() -> str: - bait_dir = Path("/tmp/ftp_bait") + """Generate a per-instance bait filesystem. + + No shared paths across deckies (/tmp/ftp_bait was identical on every + host), no tell-tale 'super_secret_admin_pw' strings. Filenames, byte + counts, and inline values are all derived from the per-decky seed, so + two honeypots never serve byte-identical files yet each stays stable + across restarts.""" + bait_dir = Path(f"/tmp/ftp-{_seed.instance_hex(6, 'ftp-bait-dir')}") bait_dir.mkdir(parents=True, exist_ok=True) - (bait_dir / "backup.tar.gz").write_bytes(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00") - (bait_dir / "db_dump.sql").write_text("CREATE TABLE users (id INT, username VARCHAR(50), password VARCHAR(50));\nINSERT INTO users VALUES (1, 'admin', 'pbkdf2:sha256:5000$...');\n") - (bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n") - (bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n") + company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"]) + env = _seed.pick(["prod", "stage", "backup", "archive"]) + year = _seed.rng.randint(2022, 2024) + month = _seed.rng.randint(1, 12) + + # Realistic-looking rotating backups. Sizes vary per instance. + for idx in range(_seed.rng.randint(2, 5)): + tag = f"{year}{month:02d}{_seed.rng.randint(1, 28):02d}" + size = _seed.rng.randint(2048, 32768) + (bait_dir / f"{company}-{env}-{tag}.tar.gz").write_bytes( + b"\x1f\x8b\x08\x00" + _seed.random_bytes(size - 4, f"tar-{idx}") + ) + + # A plausible README that looks like legacy ops notes, NOT a credential + # dump. No "password = ..." strings — those are a dead giveaway. + (bait_dir / "README.txt").write_text( + f"{company} {env} drop area\n" + f"Rotation: keep last 14, nightly rsync from db{_seed.rng.randint(1,9)}.{env}\n" + f"Contact: ops-{env}@{company}.internal\n" + ) + (bait_dir / ".htaccess").write_text("Options -Indexes\n") return str(bait_dir) + +_BAIT_PATH = _setup_bait_fs() + + class ServerFTP(FTP): def connectionMade(self): peer = self.transport.getPeer() @@ -49,10 +101,20 @@ class ServerFTP(FTP): def ftp_PASS(self, password): _log("auth_attempt", username=getattr(self, "_server_user", "?"), password=password) - # Accept everything — we're a honeypot server + # Decide whether this attempt succeeds. Unseeded randomness so + # scanners can't predict which creds will "work". + import random as _rand + if _LOGIN_MODE == "never": + accept = False + elif _LOGIN_MODE == "always": + accept = True + else: + accept = _rand.random() < _LOGIN_SUCCESS_RATE + if not accept: + return defer.succeed((530, "Login incorrect.")) self.state = self.AUTHED self._user = getattr(self, "_server_user", "anonymous") - self.shell = FTPAnonymousShell(FilePath(_setup_bait_fs())) + self.shell = FTPAnonymousShell(FilePath(_BAIT_PATH)) return defer.succeed((230, "Login successful.")) def ftp_RETR(self, path): @@ -64,6 +126,7 @@ class ServerFTP(FTP): _log("disconnect", src_ip=peer.host, src_port=peer.port) super().connectionLost(reason) + class ServerFTPFactory(FTPFactory): protocol = ServerFTP welcomeMessage = BANNER diff --git a/decnet/templates/http/server.py b/decnet/templates/http/server.py index b1698040..cf664f1c 100644 --- a/decnet/templates/http/server.py +++ b/decnet/templates/http/server.py @@ -12,6 +12,8 @@ from pathlib import Path from flask import Flask, request, send_from_directory from werkzeug.serving import make_server, WSGIRequestHandler + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog logging.getLogger("werkzeug").setLevel(logging.ERROR) @@ -20,7 +22,24 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver") SERVICE_NAME = "http" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "80")) -SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") + +# Per-instance Server header. Every decky running one identical Apache +# version string is a one-query fleet discovery for any scanner. +# Distribution shaped toward currently-deployed-in-the-wild versions. +_SERVER_CHOICES = [ + "Apache/2.4.41 (Ubuntu)", + "Apache/2.4.52 (Ubuntu)", + "Apache/2.4.54 (Debian)", + "Apache/2.4.56 (Debian)", + "Apache/2.4.57 (Debian)", + "Apache/2.4.58 (Ubuntu)", + "Apache/2.4.59 (Debian)", + "nginx/1.18.0 (Ubuntu)", + "nginx/1.22.1", + "nginx/1.24.0 (Ubuntu)", + "nginx/1.25.3", +] +SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES) 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", "{}")) diff --git a/decnet/templates/https/server.py b/decnet/templates/https/server.py index 40fd785b..1f18e496 100644 --- a/decnet/templates/https/server.py +++ b/decnet/templates/https/server.py @@ -14,6 +14,8 @@ from pathlib import Path from flask import Flask, request, send_from_directory from werkzeug.serving import make_server, WSGIRequestHandler + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog logging.getLogger("werkzeug").setLevel(logging.ERROR) @@ -22,7 +24,21 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver") SERVICE_NAME = "https" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "443")) -SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") + +_SERVER_CHOICES = [ + "Apache/2.4.41 (Ubuntu)", + "Apache/2.4.52 (Ubuntu)", + "Apache/2.4.54 (Debian)", + "Apache/2.4.56 (Debian)", + "Apache/2.4.57 (Debian)", + "Apache/2.4.58 (Ubuntu)", + "Apache/2.4.59 (Debian)", + "nginx/1.18.0 (Ubuntu)", + "nginx/1.22.1", + "nginx/1.24.0 (Ubuntu)", + "nginx/1.25.3", +] +SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES) 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", "{}")) diff --git a/decnet/templates/ldap/server.py b/decnet/templates/ldap/server.py index c7d4136c..8b89c706 100644 --- a/decnet/templates/ldap/server.py +++ b/decnet/templates/ldap/server.py @@ -7,12 +7,53 @@ invalidCredentials error. Logs all interactions as JSON. import asyncio import os +import re + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "ldapserver") SERVICE_NAME = "ldap" LOG_TARGET = os.environ.get("LOG_TARGET", "") +# RFC 4514 distinguished-name grammar: DN is a sequence of comma-separated +# RDNs like "cn=foo,ou=people,dc=example,dc=com". Each RDN is +# attribute=value, attribute matches [A-Za-z][A-Za-z0-9-]*. We keep this +# check loose on value contents (commas can be escaped etc.) but tight on +# shape, so garbage like `"abc"` or `\x00\x00` gets rejected with +# invalidDNSyntax (34) instead of invalidCredentials (49) — that's how a +# real OpenLDAP replies. +_RDN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*=.+$") + + +def _is_valid_dn(dn: str) -> bool: + """True for empty (anonymous bind) or RFC 4514-shaped DN.""" + if dn == "": + return True + if len(dn) > 1024: + return False + # Split on unescaped commas. Not perfect, but catches the obvious + # "not a DN" inputs (missing '=' in some RDN, empty segments, etc.). + parts: list[str] = [] + buf = "" + escape = False + for ch in dn: + if escape: + buf += ch + escape = False + continue + if ch == "\\": + buf += ch + escape = True + continue + if ch == ",": + parts.append(buf) + buf = "" + continue + buf += ch + parts.append(buf) + return all(_RDN_RE.match(p.strip()) for p in parts) + @@ -75,12 +116,14 @@ def _parse_bind_request(msg: bytes): return "", "" -def _bind_error_response(message_id: int) -> bytes: - # BindResponse: resultCode=49 (invalidCredentials), matchedDN="", errorMessage="" - result_code = bytes([0x0a, 0x01, 0x31]) # ENUMERATED 49 - matched_dn = bytes([0x04, 0x00]) # empty OCTET STRING - error_msg = bytes([0x04, 0x00]) # empty OCTET STRING - bind_resp_body = result_code + matched_dn + error_msg +def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes: + """BindResponse with a configurable resultCode + diagnosticMessage. + 49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform.""" + err_bytes = error_text.encode() + result_enc = bytes([0x0a, 0x01, result_code & 0xff]) + matched_dn = bytes([0x04, 0x00]) + error_msg = bytes([0x04, len(err_bytes)]) + err_bytes + bind_resp_body = result_enc + matched_dn + error_msg bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body msg_id_enc = bytes([0x02, 0x01, message_id & 0xff]) @@ -131,7 +174,17 @@ class LDAPProtocol(asyncio.Protocol): message_id = 1 dn, password = _parse_bind_request(msg) _log("bind", src=self._peer[0], dn=dn, password=password) - self._transport.write(_bind_error_response(message_id)) + _seed.jitter_sync(10, 60) + if dn and not _is_valid_dn(dn): + # OpenLDAP returns invalidDNSyntax (34) for malformed DNs, with + # a diagnostic like: "invalid DN syntax". Matching that exactly + # keeps the decoy consistent with what a scanner expects. + self._transport.write(_bind_error_response( + message_id, result_code=34, + error_text="invalid DN" + )) + else: + self._transport.write(_bind_error_response(message_id)) def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") diff --git a/decnet/templates/mongodb/server.py b/decnet/templates/mongodb/server.py index ce14f02e..cbabf1bc 100644 --- a/decnet/templates/mongodb/server.py +++ b/decnet/templates/mongodb/server.py @@ -9,6 +9,8 @@ received messages as JSON. import asyncio import os import struct + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mongodb") @@ -16,6 +18,25 @@ SERVICE_NAME = "mongodb" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "27017")) +# Per-instance (version, maxWireVersion) — paired per real MongoDB release. +# Wire version is locked to major/minor per upstream release notes. +_MONGO_RELEASES = [ + ("4.4.22", 9), + ("5.0.25", 13), + ("6.0.5", 17), + ("6.0.14", 17), + ("7.0.5", 21), + ("7.0.8", 21), + ("7.0.11", 21), +] +_MONGO_VERSION, _MONGO_WIRE = _seed.pick(_MONGO_RELEASES) +_MONGO_SET_NAME = os.environ.get("MONGO_REPL_SET", "") # empty = standalone + + +def _new_objectid() -> bytes: + """12-byte BSON ObjectId — fresh per call.""" + return _seed.fresh_bytes(12) + # Minimal BSON helpers def _bson_str(key: str, val: str) -> bytes: k = key.encode() + b"\x00" @@ -98,14 +119,23 @@ class MongoDBProtocol(asyncio.Protocol): opcode = struct.unpack(" dict: _log("config_error", severity=4, error=str(e)) if MQTT_PERSONA == "water_plant": + site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"]) topics.update({ - "plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}", - "plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}", - "plant/water/pump1/status": "RUNNING", - "plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}", - "plant/water/pump2/status": "STANDBY", - "plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}", - "plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}", - "plant/water/valve/inlet/state": "OPEN", - "plant/water/valve/drain/state": "CLOSED", - "plant/alarm/high_pressure": "0", - "plant/alarm/low_chlorine": "0", - "plant/alarm/pump_fault": "0", - "plant/$SYS/broker/version": "Mosquitto 2.0.15", - "plant/$SYS/broker/uptime": "2847392", + f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}", + f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}", + f"{site}/water/pump1/status": "RUNNING", + f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}", + f"{site}/water/pump2/status": "STANDBY", + f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}", + f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}", + f"{site}/water/valve/inlet/state": "OPEN", + f"{site}/water/valve/drain/state": "CLOSED", + f"{site}/alarm/high_pressure": "0", + f"{site}/alarm/low_chlorine": "0", + f"{site}/alarm/pump_fault": "0", }) - elif not topics: + elif MQTT_PERSONA == "building_hvac": + floor = _seed.rng.randint(1, 12) + for i in range(_seed.rng.randint(4, 10)): + topics[f"bldg/floor{floor}/zone{i}/temp"] = f"{random.uniform(20.0, 24.5):.1f}" + topics[f"bldg/floor{floor}/zone{i}/setpoint"] = f"{random.uniform(21.0, 23.0):.1f}" + topics[f"bldg/floor{floor}/ahu/status"] = _seed.pick(["RUNNING", "RUNNING", "IDLE"]) + elif MQTT_PERSONA == "solar_farm": + for arr in range(1, _seed.rng.randint(4, 9)): + topics[f"solar/array{arr}/power_kw"] = f"{random.uniform(40.0, 180.0):.1f}" + topics[f"solar/array{arr}/irradiance"] = f"{random.uniform(500, 950):.0f}" + elif MQTT_PERSONA == "factory_line": + line = _seed.pick(["A", "B", "C"]) + for m in range(1, _seed.rng.randint(3, 7)): + topics[f"line{line}/machine{m}/state"] = _seed.pick(["RUN", "RUN", "IDLE", "FAULT"]) + topics[f"line{line}/machine{m}/cycle_count"] = str(_seed.rng.randint(1000, 999_999)) + if not topics: topics = { "device/status": "online", - "device/uptime": "3600" + "device/uptime": str(_seed.uptime_seconds()), } + # $SYS keys match every real broker. + topics["$SYS/broker/version"] = _BROKER_VERSION + topics["$SYS/broker/uptime"] = f"{_seed.uptime_seconds()} seconds" + topics["$SYS/broker/clients/connected"] = str(_seed.rng.randint(2, 24)) return topics @@ -211,7 +257,13 @@ class MQTTProtocol(asyncio.Protocol): if pkt_type == 1: # CONNECT info = _parse_connect(payload) _log("auth", **info) - if MQTT_ACCEPT_ALL: + # Decide connection: accept-all > cred list > deny. + cred = (info.get("username", ""), info.get("password", "")) + accepted = ( + MQTT_ACCEPT_ALL + or (cred in _MQTT_CREDS if _MQTT_CREDS else False) + ) + if accepted: self._auth = True self._transport.write(_CONNACK_ACCEPTED) else: diff --git a/decnet/templates/mssql/server.py b/decnet/templates/mssql/server.py index 61114d59..f9eb5558 100644 --- a/decnet/templates/mssql/server.py +++ b/decnet/templates/mssql/server.py @@ -8,37 +8,73 @@ a login failed error. Logs auth attempts as JSON. import asyncio import os import struct + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "dbserver") SERVICE_NAME = "mssql" LOG_TARGET = os.environ.get("LOG_TARGET", "") -_PRELOGIN_RESP = bytes([ - 0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47 - # 0. VERSION option - 0x00, 0x00, 0x1a, 0x00, 0x06, - # 1. ENCRYPTION option - 0x01, 0x00, 0x20, 0x00, 0x01, - # 2. INSTOPT - 0x02, 0x00, 0x21, 0x00, 0x01, - # 3. THREADID - 0x03, 0x00, 0x22, 0x00, 0x04, - # 4. MARS - 0x04, 0x00, 0x26, 0x00, 0x01, - # TERMINATOR - 0xff, - # version data: 14.0.2000 - 0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00, - # encryption: NOT_SUP - 0x02, - # instopt - 0x00, - # thread id - 0x00, 0x00, 0x00, 0x00, - # mars - 0x00, -]) +# Real SQL Server release families. Pairing (major, minor, build) makes a +# subsequent OSQL/sqlcmd version probe line up with what MS published. +# Builds are taken from publicly documented latest-CU numbers. +_MSSQL_RELEASES = [ + # (name, major, minor, build, subbuild) + ("SQL Server 2016", 13, 0, 6419, 0), + ("SQL Server 2017", 14, 0, 2000, 0), + ("SQL Server 2017", 14, 0, 3460, 0), + ("SQL Server 2019", 15, 0, 2000, 0), + ("SQL Server 2019", 15, 0, 4335, 1), + ("SQL Server 2022", 16, 0, 1000, 0), + ("SQL Server 2022", 16, 0, 4115, 2), +] +_MSSQL_NAME, _VER_MAJ, _VER_MIN, _VER_BUILD, _VER_SUB = _seed.pick(_MSSQL_RELEASES) + + +def _build_prelogin_response() -> bytes: + """TDS PRELOGIN response. Version option carries + major(1) minor(1) build(2, network order) subbuild(2, network order).""" + version_data = ( + bytes([_VER_MAJ & 0xff, _VER_MIN & 0xff]) + + struct.pack(">H", _VER_BUILD & 0xffff) + + struct.pack(">H", _VER_SUB & 0xffff) + ) + # Option directory + data. Offsets are from start of directory. + # Five options: VERSION, ENCRYPTION, INSTOPT, THREADID, MARS. + # Data fields, in order: + encryption = b"\x02" # NOT_SUP + instopt = b"\x00" + threadid = struct.pack(" None: + nonlocal directory, data, running_offset + directory += bytes([token]) + struct.pack(">H", running_offset) + struct.pack(">H", len(chunk)) + data += chunk + running_offset += len(chunk) + + add_option(0x00, version_data) + add_option(0x01, encryption) + add_option(0x02, instopt) + add_option(0x03, threadid) + add_option(0x04, mars) + directory += b"\xff" + + payload = directory + data + total_len = 8 + len(payload) + header = struct.pack(">BBHBBBB", 0x04, 0x01, total_len, 0x00, 0x00, 0x01, 0x00) + return header + payload + + +_PRELOGIN_RESP = _build_prelogin_response() diff --git a/decnet/templates/mysql/server.py b/decnet/templates/mysql/server.py index a6b1d94c..13d8f323 100644 --- a/decnet/templates/mysql/server.py +++ b/decnet/templates/mysql/server.py @@ -7,32 +7,56 @@ attempts as JSON. """ import asyncio +import itertools import os import struct + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "dbserver") SERVICE_NAME = "mysql" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "3306")) -_MYSQL_VER = os.environ.get("MYSQL_VERSION", "5.7.38-log") -# Minimal MySQL server greeting (protocol v10) — version string is configurable -_GREETING = ( - b"\x0a" # protocol version 10 - + _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 -) +# Per-instance version. Real fleets never run one identical point release +# across every host — weighted mix of still-in-the-wild 5.7/8.0 builds. +_MYSQL_VER = os.environ.get("MYSQL_VERSION") or _seed.pick_weighted([ + ("5.7.38-log", 1), + ("5.7.43-log", 2), + ("5.7.44-log", 2), + ("8.0.32", 2), + ("8.0.35", 3), + ("8.0.36", 3), + ("8.0.39", 2), + ("8.0.40", 1), +]) + +# Monotonic per-process counter for connection IDs. Seeded with a +# per-instance base so two deckies never hand out id=1 to the same scanner. +_CONN_ID_SEQ = itertools.count(_seed.rng.randint(17, 65_000)) + + +def _build_greeting(conn_id: int, salt: bytes) -> bytes: + """MySQL protocol v10 Initial Handshake Packet. salt is 20 bytes + (8 + 12 split across two sections) and must be freshly random per + connection — it's the challenge the client hashes its password against.""" + assert len(salt) == 20 + return ( + b"\x0a" + + _MYSQL_VER.encode() + b"\x00" + + struct.pack(" bytes: @@ -54,12 +78,17 @@ class MySQLProtocol(asyncio.Protocol): self._peer = None self._buf = b"" self._greeted = False + self._conn_id = next(_CONN_ID_SEQ) & 0xFFFFFFFF + # 20-byte scramble; fresh per connection so two handshakes to the + # same decky never present identical auth challenges. + self._salt = _seed.fresh_bytes(20) def connection_made(self, transport): self._transport = transport self._peer = transport.get_extra_info("peername", ("?", 0)) - _log("connect", src=self._peer[0], src_port=self._peer[1]) - transport.write(_make_packet(_GREETING, seq=0)) + _log("connect", src=self._peer[0], src_port=self._peer[1], + connection_id=self._conn_id) + transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0)) self._greeted = True def data_received(self, data): @@ -81,19 +110,24 @@ class MySQLProtocol(asyncio.Protocol): if not payload: return # Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated) + username = "" if len(payload) > 32: try: - # skip capability(4) + max_pkt(4) + charset(1) + reserved(23) = 32 bytes username_start = 32 nul = payload.index(b"\x00", username_start) username = payload[username_start:nul].decode(errors="replace") except (ValueError, IndexError): username = "" - _log("auth", src=self._peer[0], username=username) - # Send Access Denied error - err = b"\xff" + struct.pack(" bytes: - body = b"S" + b"FATAL\x00" + b"M" + message.encode() + b"\x00\x00" + +# Per-instance list of "existing" databases. A real server knows which dbs +# it hosts and returns SQLSTATE 3D000 "database does not exist" for anything +# else — refusing with "password authentication failed" for every single +# probe is a strong honeypot signal. +_BASE_DBS = {"postgres", "template0", "template1"} +_APP_DB_CHOICES = [ + ["app", "app_prod"], + ["webapp", "sessions"], + ["erp", "erp_hist"], + ["django", "django_cache"], + ["rails_production"], + ["wordpress"], + ["gitlabhq_production"], + ["metrics", "grafana"], +] +_DATABASES = _BASE_DBS | set(_seed.pick(_APP_DB_CHOICES)) + + +def _error_response(severity: str, sqlstate: str, message: str) -> bytes: + """Wire-level PG ErrorResponse. Fields: S (localized severity), V + (non-localized severity, PG 9.6+), C (SQLSTATE), M (message).""" + body = ( + b"S" + severity.encode() + b"\x00" + + b"V" + severity.encode() + b"\x00" + + b"C" + sqlstate.encode() + b"\x00" + + b"M" + message.encode() + b"\x00" + + b"\x00" + ) return b"E" + struct.pack(">I", len(body) + 4) + body @@ -90,8 +119,18 @@ class PostgresProtocol(asyncio.Protocol): if k: params[k] = v username = params.get("user", "") - database = params.get("database", "") + database = params.get("database", "") or username + self._username = username + self._database = database _log("startup", src=self._peer[0], username=username, database=database) + # If the requested DB doesn't exist on this instance, real Postgres + # rejects *before* asking for a password. Short-circuit so the decoy + # matches that behavior and exposes the per-decky DB list. + if database and database not in _DATABASES: + msg = f'database "{database}" does not exist' + self._transport.write(_error_response("FATAL", "3D000", msg)) + self._transport.close() + return self._state = "auth" salt = os.urandom(4) auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt @@ -99,8 +138,13 @@ class PostgresProtocol(asyncio.Protocol): def _handle_password(self, payload: bytes): pw_hash = payload.rstrip(b"\x00").decode(errors="replace") - _log("auth", src=self._peer[0], pw_hash=pw_hash) - self._transport.write(_error_response("password authentication failed")) + _log("auth", src=self._peer[0], pw_hash=pw_hash, + username=getattr(self, "_username", ""), + database=getattr(self, "_database", "")) + user = getattr(self, "_username", "") + msg = f'password authentication failed for user "{user}"' + _seed.jitter_sync(20, 90) + self._transport.write(_error_response("FATAL", "28P01", msg)) self._transport.close() def connection_lost(self, exc): diff --git a/decnet/templates/redis/server.py b/decnet/templates/redis/server.py index 4d3242fb..db8c1151 100644 --- a/decnet/templates/redis/server.py +++ b/decnet/templates/redis/server.py @@ -7,38 +7,111 @@ KEYS, and arbitrary commands. Logs every command and argument as JSON. import asyncio import os + +import instance_seed as _seed from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "cache-server") SERVICE_NAME = "redis" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "6379")) -_REDIS_VER = os.environ.get("REDIS_VERSION", "7.2.7") -_REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0") -_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() +# Per-instance realistic version pick (weighted toward still-supported lines). +_REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([ + ("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2), + ("7.0.15", 2), ("7.0.14", 1), + ("6.2.14", 2), ("6.2.16", 1), +]) +# Kernel line matching plausible Debian/Ubuntu LTS minor ranges. +_REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([ + "Linux 5.15.0-118-generic x86_64", + "Linux 6.1.0-21-amd64 x86_64", + "Linux 5.10.0-30-amd64 x86_64", + "Linux 6.5.0-27-generic x86_64", +]) +_RUN_ID = _seed.instance_hex(20, "redis-run") +_PROCESS_ID = _seed.rng.randint(120, 32000) +_TCP_PORT_STR = str(PORT) -_FAKE_STORE = { - b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}', - b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}', - b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ", - b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024", - b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}', - b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}', - b"config:db_password": b"Pr0dDB!2024#Secure", - b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE", - b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - b"rate_limit:192.168.1.1": b"42", +# AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns +# the canonical "Client sent AUTH, but no password is set" error, matching a +# real redis-server with requirepass unset. +_REQUIREPASS = os.environ.get("REDIS_PASSWORD", "") + + +def _info_block() -> bytes: + uptime = _seed.uptime_seconds() + uptime_days = max(1, uptime // 86400) + # Minimal but plausible subset; real redis INFO has ~150 keys. + text = ( + "# Server\r\n" + f"redis_version:{_REDIS_VER}\r\n" + f"redis_git_sha1:00000000\r\n" + f"redis_git_dirty:0\r\n" + f"redis_build_id:{_seed.instance_hex(8, 'redis-build')}\r\n" + "redis_mode:standalone\r\n" + f"os:{_REDIS_OS}\r\n" + "arch_bits:64\r\n" + f"process_id:{_PROCESS_ID}\r\n" + f"run_id:{_RUN_ID}\r\n" + f"tcp_port:{_TCP_PORT_STR}\r\n" + f"uptime_in_seconds:{uptime}\r\n" + f"uptime_in_days:{uptime_days}\r\n" + "hz:10\r\n" + "# Clients\r\n" + "connected_clients:1\r\n" + "maxclients:10000\r\n" + "# Memory\r\n" + f"used_memory:{_seed.rng.randint(800_000, 12_000_000)}\r\n" + "mem_fragmentation_ratio:1.12\r\n" + "# Stats\r\n" + f"total_connections_received:{_seed.rng.randint(50, 9000)}\r\n" + f"total_commands_processed:{_seed.rng.randint(5_000, 2_000_000)}\r\n" + "# Keyspace\r\n" + ) + return text.encode() + + +def _build_fake_store() -> dict[bytes, bytes]: + """Per-instance plausible cache content. No embedded DECNET-identifying + strings; keys / values shaped like what real apps leave in redis.""" + n_sessions = _seed.rng.randint(3, 14) + store: dict[bytes, bytes] = {} + app_slug = _seed.pick(["api", "web", "worker", "shop", "admin", "cms"]) + env_slug = _seed.pick(["prod", "stage", "live"]) + for i in range(n_sessions): + sid = _seed.instance_hex(16, f"sess-{i}") + uid = _seed.rng.randint(1000, 999_999) + store[f"session:{sid}".encode()] = ( + f'{{"uid":{uid},"exp":{int(_seed.boot_epoch()) + 86400 * 7}}}' + ).encode() + for i in range(_seed.rng.randint(2, 6)): + store[f"cache:{app_slug}:feed:{i}".encode()] = ( + _seed.instance_hex(24, f"feed-{i}").encode() + ) + store[f"stats:{app_slug}:{env_slug}:requests".encode()] = ( + str(_seed.rng.randint(5_000, 900_000)).encode() + ) + return store + + +_FAKE_STORE = _build_fake_store() + +# Config presented via CONFIG GET — realistic subset of a default redis.conf. +_CONFIG = { + "maxmemory": "0", + "maxmemory-policy": "noeviction", + "maxclients": "10000", + "timeout": "0", + "tcp-keepalive": "300", + "databases": "16", + "save": "3600 1 300 100 60 10000", + "appendonly": "no", + "loglevel": "notice", + "dir": "/var/lib/redis", + "bind": "127.0.0.1 -::1", + "protected-mode": "yes", + "supervised": "systemd", } @@ -114,11 +187,22 @@ class RESPParser: return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1) +def _config_get(pattern: str) -> bytes: + """Emulate `CONFIG GET ` — returns alternating key/value bulks.""" + import fnmatch + matches = [(k, v) for k, v in _CONFIG.items() if fnmatch.fnmatchcase(k, pattern)] + out = f"*{len(matches) * 2}\r\n".encode() + for k, v in matches: + out += _bulk(k) + _bulk(v) + return out + + class RedisProtocol(asyncio.Protocol): def __init__(self): self._transport = None self._peer = None self._parser = RESPParser() + self._authed = not _REQUIREPASS # auth satisfied iff no password set def connection_made(self, transport): self._transport = transport @@ -129,6 +213,16 @@ class RedisProtocol(asyncio.Protocol): for cmd in self._parser.feed(data): self._handle_command(cmd) + def _write(self, payload: bytes) -> None: + """Writes with per-response jitter. Unseeded so two connections to + the same decky don't get an identical latency fingerprint. Honeypot + throughput targets are low; a few ms of blocking sleep here is fine + and avoids the asyncio-task plumbing the synchronous protocol model + doesn't otherwise need.""" + _seed.jitter_sync(2, 40) + if self._transport and not self._transport.is_closing(): + self._transport.write(payload) + def _handle_command(self, parts): if not parts: return @@ -137,15 +231,40 @@ class RedisProtocol(asyncio.Protocol): _log("command", src=self._peer[0], cmd=verb, args=args[:8]) if verb == "AUTH": - password = args[0] if args else "" + password = args[-1] if args else "" _log("auth", src=self._peer[0], password=password) - self._transport.write(b"+OK\r\n") - elif verb == "INFO": - self._transport.write(f"${len(_INFO)}\r\n".encode() + _INFO + b"\r\n") + if not _REQUIREPASS: + self._write( + _err("Client sent AUTH, but no password is set. " + "Did you mean AUTH ?") + ) + elif password == _REQUIREPASS: + self._authed = True + self._write(b"+OK\r\n") + else: + self._write(_err("WRONGPASS invalid username-password pair or user is disabled.")) + return + if not self._authed: + self._write(_err("NOAUTH Authentication required.")) + return + if verb == "INFO": + info = _info_block() + self._write(f"${len(info)}\r\n".encode() + info + b"\r\n") elif verb == "PING": - self._transport.write(b"+PONG\r\n") + self._write(b"+PONG\r\n") elif verb == "CONFIG": - self._transport.write(b"*0\r\n") + sub = args[0].upper() if args else "" + if sub == "GET" and len(args) >= 2: + self._write(_config_get(args[1])) + elif sub == "SET": + self._write(b"+OK\r\n") + elif sub == "RESETSTAT": + self._write(b"+OK\r\n") + else: + self._write(_err( + "Unknown CONFIG subcommand or wrong number of arguments for '" + f"{sub.lower() or '?'}'" + )) elif verb == "KEYS": pattern = args[0] if args else "*" keys = list(_FAKE_STORE.keys()) @@ -157,26 +276,35 @@ class RedisProtocol(asyncio.Protocol): keys = [k for k in keys if k == pat] resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys) - self._transport.write(resp) + self._write(resp) elif verb == "GET": key = args[0].encode() if args else b"" if key in _FAKE_STORE: - self._transport.write(_bulk(_FAKE_STORE[key].decode())) + self._write(_bulk(_FAKE_STORE[key].decode())) else: - self._transport.write(b"$-1\r\n") + self._write(b"$-1\r\n") elif verb == "SCAN": keys = list(_FAKE_STORE.keys()) resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys) - self._transport.write(resp) + self._write(resp) elif verb == "TYPE": - self._transport.write(b"+string\r\n") + self._write(b"+string\r\n") elif verb == "TTL": - self._transport.write(b":-1\r\n") + self._write(b":-1\r\n") + elif verb == "DBSIZE": + self._write(f":{len(_FAKE_STORE)}\r\n".encode()) + elif verb == "COMMAND": + self._write(b"*0\r\n") + elif verb == "CLIENT": + self._write(b"+OK\r\n") + elif verb == "SELECT": + self._write(b"+OK\r\n") elif verb == "QUIT": - self._transport.write(b"+OK\r\n") - self._transport.close() + self._write(b"+OK\r\n") + if self._transport: + self._transport.close() else: - self._transport.write(_err("unknown command")) + self._write(_err(f"unknown command '{verb.lower()}'")) def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") diff --git a/decnet/templates/smtp/server.py b/decnet/templates/smtp/server.py index 9cd52a26..ca54cd52 100644 --- a/decnet/templates/smtp/server.py +++ b/decnet/templates/smtp/server.py @@ -21,8 +21,11 @@ The DATA state machine (and the 502-per-line bug) is fixed in both modes. import asyncio import base64 import os -import random -import string +import random as _rand +import re +import time + +import instance_seed as _seed from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mailserver") @@ -31,9 +34,27 @@ LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "25")) OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1" +# In open-relay mode, optionally restrict which creds succeed. Blank means +# "accept anything". Format: "user1,user2,..." — any name not in the list +# gets a 535 instead of 235, so the relay looks realistically selective. +_AUTH_WHITELIST = {u.strip() for u in os.environ.get("SMTP_AUTH_WHITELIST", "").split(",") if u.strip()} + +# Open-relay filtering. Even compromised/misconfigured relays aren't pure +# tarpits — Postfix rejects malformed addresses at RCPT time, and many drop +# a small fraction of external recipients under greylisting or reputation +# checks. Accepting literally every RCPT is a honeypot tell. +_ADDR_RE = re.compile(r"^@]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})>?$") +_BLOCKED_TLDS = {"invalid", "test", "localhost", "local", "example"} +_RCPT_DROP_RATE = float(os.environ.get("SMTP_RCPT_DROP_RATE", "0.08")) + _SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)") _SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME) +# Postfix's queue-ID character set (real one: excludes vowels and look-alikes +# like 0/O, 1/I, so scanners that know Postfix's alphabet are satisfied). +_QUEUE_CHARS = "BCDFGHJKLMNPQRSTVWXYZ23456789" +_Q_BASE = len(_QUEUE_CHARS) + def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) @@ -42,9 +63,23 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None: def _rand_msg_id() -> str: - """Return a Postfix-style 12-char alphanumeric queue ID.""" - chars = string.ascii_uppercase + string.digits - return "".join(random.choices(chars, k=12)) + """Postfix-style queue ID. + + Real Postfix derives its short queue IDs from the message's arrival + microseconds, base-encoded with a vowel-free alphabet — so IDs are + monotonically increasing and visually distinctive. We encode the current + microsecond count with Postfix's actual character set, then append a + short per-instance suffix so two deckies never emit identical IDs at + the same instant. + """ + us = int(time.time() * 1_000_000) + out: list[str] = [] + while us and len(out) < 10: + us, r = divmod(us, _Q_BASE) + out.append(_QUEUE_CHARS[r]) + base = "".join(reversed(out)) or _QUEUE_CHARS[0] + suffix_idx = _seed.rng.randint(0, _Q_BASE - 1) + return base + _QUEUE_CHARS[suffix_idx] def _decode_auth_plain(blob: str) -> tuple[str, str]: @@ -108,6 +143,9 @@ class SMTPProtocol(asyncio.Protocol): rcpt_to=",".join(self._rcpt_to), body_bytes=len(body), msg_id=msg_id) + # Real MTAs take tens of ms to queue; instantaneous replies + # on DATA are a tell. + _seed.jitter_sync(30, 180) self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode()) self._in_data = False self._data_buf = [] @@ -172,9 +210,30 @@ class SMTPProtocol(asyncio.Protocol): elif cmd == "RCPT": addr = args.split(":", 1)[1].strip() if ":" in args else args if OPEN_RELAY: - self._rcpt_to.append(addr) - _log("rcpt_to", src=self._peer[0], value=addr) - self._transport.write(b"250 2.1.5 Ok\r\n") + match = _ADDR_RE.match(addr) + if not match: + _log("rcpt_rejected_syntax", src=self._peer[0], value=addr, + severity=SEVERITY_WARNING) + self._transport.write( + b"501 5.1.3 Bad recipient address syntax\r\n" + ) + elif match.group(2).rsplit(".", 1)[-1].lower() in _BLOCKED_TLDS: + _log("rcpt_rejected_tld", src=self._peer[0], value=addr, + severity=SEVERITY_WARNING) + self._transport.write( + b"550 5.1.2 <" + addr.encode() + + b">: Recipient address rejected: Domain not found\r\n" + ) + elif _rand.random() < _RCPT_DROP_RATE: + _log("rcpt_greylisted", src=self._peer[0], value=addr) + self._transport.write( + b"451 4.7.1 <" + addr.encode() + + b">: Recipient address rejected: Greylisted, try again later\r\n" + ) + else: + self._rcpt_to.append(addr) + _log("rcpt_to", src=self._peer[0], value=addr) + self._transport.write(b"250 2.1.5 Ok\r\n") else: _log("rcpt_denied", src=self._peer[0], value=addr, severity=SEVERITY_WARNING) @@ -246,7 +305,14 @@ class SMTPProtocol(asyncio.Protocol): _log("auth_attempt", src=self._peer[0], username=username, password=password, severity=SEVERITY_WARNING) - if OPEN_RELAY: + if not OPEN_RELAY: + self._transport.write(b"535 5.7.8 Error: authentication failed\r\n") + return + # Open-relay mode: still be selective so the decoy doesn't look like a + # tarpit that accepts literally anything. If no whitelist is set, + # accept; otherwise gate on username presence. + accepted = not _AUTH_WHITELIST or username in _AUTH_WHITELIST + if accepted: self._transport.write(b"235 2.7.0 Authentication successful\r\n") else: self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")