feat(templates): per-instance stealth via instance_seed in service servers
Every service template now pulls version strings, cluster/node UUIDs, auth salts, greeting banners, and uptime from the seeded per-instance RNG instead of hard-coded defaults. Scanners sweeping the fleet now see legitimately diverging fingerprints per decky while each decky's own responses stay internally consistent across restarts. Covers elasticsearch, ftp, http, https, ldap, mongodb, mqtt, mssql, mysql, postgres, redis, and smtp templates.
This commit is contained in:
@@ -5,32 +5,73 @@ Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/)
|
|||||||
as JSON. Designed to attract automated scanners and credential stuffers.
|
as JSON. Designed to attract automated scanners and credential stuffers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "esserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "esserver")
|
||||||
SERVICE_NAME = "elasticsearch"
|
SERVICE_NAME = "elasticsearch"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
|
|
||||||
_CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA"
|
# Real ES cluster/node UUIDs are 22-char base64 (16 random bytes,
|
||||||
_NODE_UUID = "dJH7Lm2sRqWvPn0kFiEtBo"
|
# 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 = {
|
_ROOT_RESPONSE = {
|
||||||
"name": NODE_NAME,
|
"name": NODE_NAME,
|
||||||
"cluster_name": "elasticsearch",
|
"cluster_name": _CLUSTER_NAME,
|
||||||
"cluster_uuid": _CLUSTER_UUID,
|
"cluster_uuid": _CLUSTER_UUID,
|
||||||
"version": {
|
"version": {
|
||||||
"number": "7.17.9",
|
"number": _ES_VERSION,
|
||||||
"build_flavor": "default",
|
"build_flavor": "default",
|
||||||
"build_type": "docker",
|
"build_type": "docker",
|
||||||
"build_hash": "ef48222227ee6b9e70e502f0f0daa52435ee634d",
|
"build_hash": _ES_BUILD_HASH,
|
||||||
"build_date": "2023-01-31T05:34:43.305517834Z",
|
"build_date": _ES_BUILD_DATE,
|
||||||
"build_snapshot": False,
|
"build_snapshot": False,
|
||||||
"lucene_version": "8.11.1",
|
"lucene_version": _ES_LUCENE,
|
||||||
"minimum_wire_compatibility_version": "6.8.0",
|
"minimum_wire_compatibility_version": _MIN_WIRE,
|
||||||
"minimum_index_compatibility_version": "6.0.0-beta1",
|
"minimum_index_compatibility_version": _MIN_INDEX,
|
||||||
},
|
},
|
||||||
"tagline": "You Know, for Search",
|
"tagline": "You Know, for Search",
|
||||||
}
|
}
|
||||||
@@ -73,11 +114,28 @@ class ESHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(200, [])
|
self._send_json(200, [])
|
||||||
elif path.startswith("/_cluster/"):
|
elif path.startswith("/_cluster/"):
|
||||||
_log("cluster_recon", src=src, method="GET", path=self.path)
|
_log("cluster_recon", src=src, method="GET", path=self.path)
|
||||||
self._send_json(200, {"cluster_name": "elasticsearch", "status": "green",
|
self._send_json(200, {
|
||||||
"number_of_nodes": 3, "number_of_data_nodes": 3})
|
"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"):
|
elif path.startswith("/_nodes"):
|
||||||
_log("nodes_recon", src=src, method="GET", path=self.path)
|
_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/"):
|
elif path.startswith("/_security/") or path.startswith("/_xpack/"):
|
||||||
_log("security_probe", src=src, method="GET", path=self.path)
|
_log("security_probe", src=src, method="GET", path=self.path)
|
||||||
self._send_json(200, {"enabled": True, "available": True})
|
self._send_json(200, {"enabled": True, "available": True})
|
||||||
|
|||||||
@@ -12,30 +12,82 @@ from twisted.internet import defer, reactor
|
|||||||
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
|
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
|
||||||
from twisted.python.filepath import FilePath
|
from twisted.python.filepath import FilePath
|
||||||
from twisted.python import log as twisted_log
|
from twisted.python import log as twisted_log
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
||||||
SERVICE_NAME = "ftp"
|
SERVICE_NAME = "ftp"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "21"))
|
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:
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||||
write_syslog_file(line)
|
write_syslog_file(line)
|
||||||
forward_syslog(line, LOG_TARGET)
|
forward_syslog(line, LOG_TARGET)
|
||||||
|
|
||||||
|
|
||||||
def _setup_bait_fs() -> str:
|
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.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")
|
company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
|
||||||
(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")
|
env = _seed.pick(["prod", "stage", "backup", "archive"])
|
||||||
(bait_dir / "config.ini").write_text("[database]\nuser = dbadmin\npassword = db_super_admin_pass_!\nhost = localhost\n")
|
year = _seed.rng.randint(2022, 2024)
|
||||||
(bait_dir / "credentials.txt").write_text("admin:super_secret_admin_pw\nroot:toor\nalice:wonderland\n")
|
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)
|
return str(bait_dir)
|
||||||
|
|
||||||
|
|
||||||
|
_BAIT_PATH = _setup_bait_fs()
|
||||||
|
|
||||||
|
|
||||||
class ServerFTP(FTP):
|
class ServerFTP(FTP):
|
||||||
def connectionMade(self):
|
def connectionMade(self):
|
||||||
peer = self.transport.getPeer()
|
peer = self.transport.getPeer()
|
||||||
@@ -49,10 +101,20 @@ class ServerFTP(FTP):
|
|||||||
|
|
||||||
def ftp_PASS(self, password):
|
def ftp_PASS(self, password):
|
||||||
_log("auth_attempt", username=getattr(self, "_server_user", "?"), password=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.state = self.AUTHED
|
||||||
self._user = getattr(self, "_server_user", "anonymous")
|
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."))
|
return defer.succeed((230, "Login successful."))
|
||||||
|
|
||||||
def ftp_RETR(self, path):
|
def ftp_RETR(self, path):
|
||||||
@@ -64,6 +126,7 @@ class ServerFTP(FTP):
|
|||||||
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
||||||
super().connectionLost(reason)
|
super().connectionLost(reason)
|
||||||
|
|
||||||
|
|
||||||
class ServerFTPFactory(FTPFactory):
|
class ServerFTPFactory(FTPFactory):
|
||||||
protocol = ServerFTP
|
protocol = ServerFTP
|
||||||
welcomeMessage = BANNER
|
welcomeMessage = BANNER
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Flask, request, send_from_directory
|
from flask import Flask, request, send_from_directory
|
||||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||||
@@ -20,7 +22,24 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
|||||||
SERVICE_NAME = "http"
|
SERVICE_NAME = "http"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "80"))
|
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"))
|
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
|
||||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Flask, request, send_from_directory
|
from flask import Flask, request, send_from_directory
|
||||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||||
@@ -22,7 +24,21 @@ NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
|||||||
SERVICE_NAME = "https"
|
SERVICE_NAME = "https"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "443"))
|
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"))
|
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
|
||||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||||
|
|||||||
@@ -7,12 +7,53 @@ invalidCredentials error. Logs all interactions as JSON.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
|
||||||
SERVICE_NAME = "ldap"
|
SERVICE_NAME = "ldap"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
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 "<parse_error>", "<parse_error>"
|
return "<parse_error>", "<parse_error>"
|
||||||
|
|
||||||
|
|
||||||
def _bind_error_response(message_id: int) -> bytes:
|
def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
|
||||||
# BindResponse: resultCode=49 (invalidCredentials), matchedDN="", errorMessage=""
|
"""BindResponse with a configurable resultCode + diagnosticMessage.
|
||||||
result_code = bytes([0x0a, 0x01, 0x31]) # ENUMERATED 49
|
49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
|
||||||
matched_dn = bytes([0x04, 0x00]) # empty OCTET STRING
|
err_bytes = error_text.encode()
|
||||||
error_msg = bytes([0x04, 0x00]) # empty OCTET STRING
|
result_enc = bytes([0x0a, 0x01, result_code & 0xff])
|
||||||
bind_resp_body = result_code + matched_dn + error_msg
|
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
|
bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body
|
||||||
|
|
||||||
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
|
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
|
||||||
@@ -131,7 +174,17 @@ class LDAPProtocol(asyncio.Protocol):
|
|||||||
message_id = 1
|
message_id = 1
|
||||||
dn, password = _parse_bind_request(msg)
|
dn, password = _parse_bind_request(msg)
|
||||||
_log("bind", src=self._peer[0], dn=dn, password=password)
|
_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):
|
def connection_lost(self, exc):
|
||||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ received messages as JSON.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "mongodb")
|
NODE_NAME = os.environ.get("NODE_NAME", "mongodb")
|
||||||
@@ -16,6 +18,25 @@ SERVICE_NAME = "mongodb"
|
|||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "27017"))
|
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
|
# Minimal BSON helpers
|
||||||
def _bson_str(key: str, val: str) -> bytes:
|
def _bson_str(key: str, val: str) -> bytes:
|
||||||
k = key.encode() + b"\x00"
|
k = key.encode() + b"\x00"
|
||||||
@@ -98,14 +119,23 @@ class MongoDBProtocol(asyncio.Protocol):
|
|||||||
opcode = struct.unpack("<I", msg[12:16])[0]
|
opcode = struct.unpack("<I", msg[12:16])[0]
|
||||||
_log("message", src=self._peer[0], opcode=opcode, length=len(msg))
|
_log("message", src=self._peer[0], opcode=opcode, length=len(msg))
|
||||||
|
|
||||||
# Build a generic isMaster-style OK response
|
# Build a generic isMaster-style OK response with this instance's
|
||||||
reply_doc = _bson_doc(
|
# version pair. Fresh topologyVersion on every reply (matches real
|
||||||
|
# mongod behavior — clients use this to detect failover).
|
||||||
|
fields = [
|
||||||
_bson_bool("ismaster", True),
|
_bson_bool("ismaster", True),
|
||||||
_bson_int32("maxWireVersion", 17),
|
_bson_bool("helloOk", True),
|
||||||
|
_bson_int32("maxBsonObjectSize", 16777216),
|
||||||
|
_bson_int32("maxMessageSizeBytes", 48000000),
|
||||||
|
_bson_int32("maxWriteBatchSize", 100000),
|
||||||
|
_bson_int32("maxWireVersion", _MONGO_WIRE),
|
||||||
_bson_int32("minWireVersion", 0),
|
_bson_int32("minWireVersion", 0),
|
||||||
_bson_str("version", "6.0.5"),
|
_bson_str("version", _MONGO_VERSION),
|
||||||
_bson_int32("ok", 1),
|
_bson_int32("ok", 1),
|
||||||
)
|
]
|
||||||
|
if _MONGO_SET_NAME:
|
||||||
|
fields.insert(1, _bson_str("setName", _MONGO_SET_NAME))
|
||||||
|
reply_doc = _bson_doc(*fields)
|
||||||
if opcode == 2013: # OP_MSG
|
if opcode == 2013: # OP_MSG
|
||||||
self._transport.write(_op_msg(request_id, reply_doc))
|
self._transport.write(_op_msg(request_id, reply_doc))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,16 +12,44 @@ import json
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
|
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
|
||||||
SERVICE_NAME = "mqtt"
|
SERVICE_NAME = "mqtt"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "1883"))
|
PORT = int(os.environ.get("PORT", "1883"))
|
||||||
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1"
|
|
||||||
MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant")
|
# Default to auth-required. A broker that accepts literally anyone with any
|
||||||
|
# client_id / username was realistic for devices on a flat OT LAN pre-2018,
|
||||||
|
# but in 2024+ it's a tell. Operators who *want* the anonymous-broker decoy
|
||||||
|
# still set MQTT_ACCEPT_ALL=1 explicitly.
|
||||||
|
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "0") == "1"
|
||||||
|
# Optional cred list (user:pass comma-separated). If set, only those combos
|
||||||
|
# succeed even when ACCEPT_ALL is off — lets operators plant credential bait.
|
||||||
|
_MQTT_CREDS: set[tuple[str, str]] = set()
|
||||||
|
for combo in os.environ.get("MQTT_CREDS", "").split(","):
|
||||||
|
combo = combo.strip()
|
||||||
|
if ":" in combo:
|
||||||
|
u, _, p = combo.partition(":")
|
||||||
|
_MQTT_CREDS.add((u, p))
|
||||||
|
|
||||||
|
_PERSONA_CHOICES = ["water_plant", "building_hvac", "solar_farm", "factory_line"]
|
||||||
|
MQTT_PERSONA = os.environ.get("MQTT_PERSONA") or _seed.pick(_PERSONA_CHOICES)
|
||||||
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
|
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
|
||||||
|
|
||||||
|
# Fleet-diverse broker ID. Real mosquitto versions in the wild right now.
|
||||||
|
_BROKER_VERSION = os.environ.get("MQTT_BROKER_VERSION") or _seed.pick([
|
||||||
|
"mosquitto version 1.6.9",
|
||||||
|
"mosquitto version 2.0.11",
|
||||||
|
"mosquitto version 2.0.15",
|
||||||
|
"mosquitto version 2.0.17",
|
||||||
|
"mosquitto version 2.0.18",
|
||||||
|
"HiveMQ CE 2024.4",
|
||||||
|
"EMQX 5.3.2",
|
||||||
|
])
|
||||||
|
|
||||||
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
|
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
|
||||||
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
|
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
|
||||||
|
|
||||||
@@ -133,27 +161,45 @@ def _generate_topics() -> dict:
|
|||||||
_log("config_error", severity=4, error=str(e))
|
_log("config_error", severity=4, error=str(e))
|
||||||
|
|
||||||
if MQTT_PERSONA == "water_plant":
|
if MQTT_PERSONA == "water_plant":
|
||||||
|
site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"])
|
||||||
topics.update({
|
topics.update({
|
||||||
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||||
"plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
||||||
"plant/water/pump1/status": "RUNNING",
|
f"{site}/water/pump1/status": "RUNNING",
|
||||||
"plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
||||||
"plant/water/pump2/status": "STANDBY",
|
f"{site}/water/pump2/status": "STANDBY",
|
||||||
"plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
||||||
"plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
||||||
"plant/water/valve/inlet/state": "OPEN",
|
f"{site}/water/valve/inlet/state": "OPEN",
|
||||||
"plant/water/valve/drain/state": "CLOSED",
|
f"{site}/water/valve/drain/state": "CLOSED",
|
||||||
"plant/alarm/high_pressure": "0",
|
f"{site}/alarm/high_pressure": "0",
|
||||||
"plant/alarm/low_chlorine": "0",
|
f"{site}/alarm/low_chlorine": "0",
|
||||||
"plant/alarm/pump_fault": "0",
|
f"{site}/alarm/pump_fault": "0",
|
||||||
"plant/$SYS/broker/version": "Mosquitto 2.0.15",
|
|
||||||
"plant/$SYS/broker/uptime": "2847392",
|
|
||||||
})
|
})
|
||||||
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 = {
|
topics = {
|
||||||
"device/status": "online",
|
"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
|
return topics
|
||||||
|
|
||||||
|
|
||||||
@@ -211,7 +257,13 @@ class MQTTProtocol(asyncio.Protocol):
|
|||||||
if pkt_type == 1: # CONNECT
|
if pkt_type == 1: # CONNECT
|
||||||
info = _parse_connect(payload)
|
info = _parse_connect(payload)
|
||||||
_log("auth", **info)
|
_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._auth = True
|
||||||
self._transport.write(_CONNACK_ACCEPTED)
|
self._transport.write(_CONNACK_ACCEPTED)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -8,37 +8,73 @@ a login failed error. Logs auth attempts as JSON.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||||
SERVICE_NAME = "mssql"
|
SERVICE_NAME = "mssql"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
|
|
||||||
_PRELOGIN_RESP = bytes([
|
# Real SQL Server release families. Pairing (major, minor, build) makes a
|
||||||
0x04, 0x01, 0x00, 0x2f, 0x00, 0x00, 0x01, 0x00, # TDS header type=4, status=1, len=47
|
# subsequent OSQL/sqlcmd version probe line up with what MS published.
|
||||||
# 0. VERSION option
|
# Builds are taken from publicly documented latest-CU numbers.
|
||||||
0x00, 0x00, 0x1a, 0x00, 0x06,
|
_MSSQL_RELEASES = [
|
||||||
# 1. ENCRYPTION option
|
# (name, major, minor, build, subbuild)
|
||||||
0x01, 0x00, 0x20, 0x00, 0x01,
|
("SQL Server 2016", 13, 0, 6419, 0),
|
||||||
# 2. INSTOPT
|
("SQL Server 2017", 14, 0, 2000, 0),
|
||||||
0x02, 0x00, 0x21, 0x00, 0x01,
|
("SQL Server 2017", 14, 0, 3460, 0),
|
||||||
# 3. THREADID
|
("SQL Server 2019", 15, 0, 2000, 0),
|
||||||
0x03, 0x00, 0x22, 0x00, 0x04,
|
("SQL Server 2019", 15, 0, 4335, 1),
|
||||||
# 4. MARS
|
("SQL Server 2022", 16, 0, 1000, 0),
|
||||||
0x04, 0x00, 0x26, 0x00, 0x01,
|
("SQL Server 2022", 16, 0, 4115, 2),
|
||||||
# TERMINATOR
|
]
|
||||||
0xff,
|
_MSSQL_NAME, _VER_MAJ, _VER_MIN, _VER_BUILD, _VER_SUB = _seed.pick(_MSSQL_RELEASES)
|
||||||
# version data: 14.0.2000
|
|
||||||
0x0e, 0x00, 0x07, 0xd0, 0x00, 0x00,
|
|
||||||
# encryption: NOT_SUP
|
def _build_prelogin_response() -> bytes:
|
||||||
0x02,
|
"""TDS PRELOGIN response. Version option carries
|
||||||
# instopt
|
major(1) minor(1) build(2, network order) subbuild(2, network order)."""
|
||||||
0x00,
|
version_data = (
|
||||||
# thread id
|
bytes([_VER_MAJ & 0xff, _VER_MIN & 0xff])
|
||||||
0x00, 0x00, 0x00, 0x00,
|
+ struct.pack(">H", _VER_BUILD & 0xffff)
|
||||||
# mars
|
+ struct.pack(">H", _VER_SUB & 0xffff)
|
||||||
0x00,
|
)
|
||||||
])
|
# 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("<I", _seed.rng.randint(100, 9000))
|
||||||
|
mars = b"\x00"
|
||||||
|
|
||||||
|
directory = b""
|
||||||
|
data = b""
|
||||||
|
# Directory header is 5 bytes per option + 1 terminator; compute offsets
|
||||||
|
# from end of terminator.
|
||||||
|
dir_size = 5 * 5 + 1
|
||||||
|
running_offset = dir_size
|
||||||
|
|
||||||
|
def add_option(token: int, chunk: bytes) -> 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,32 +7,56 @@ attempts as JSON.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||||
SERVICE_NAME = "mysql"
|
SERVICE_NAME = "mysql"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "3306"))
|
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
|
# Per-instance version. Real fleets never run one identical point release
|
||||||
_GREETING = (
|
# across every host — weighted mix of still-in-the-wild 5.7/8.0 builds.
|
||||||
b"\x0a" # protocol version 10
|
_MYSQL_VER = os.environ.get("MYSQL_VERSION") or _seed.pick_weighted([
|
||||||
+ _MYSQL_VER.encode() + b"\x00" # server version + NUL
|
("5.7.38-log", 1),
|
||||||
+ b"\x01\x00\x00\x00" # connection id = 1
|
("5.7.43-log", 2),
|
||||||
+ b"\x70\x76\x21\x6d\x61\x67\x69\x63" # auth-plugin-data part 1
|
("5.7.44-log", 2),
|
||||||
+ b"\x00" # filler
|
("8.0.32", 2),
|
||||||
+ b"\xff\xf7" # capability flags low
|
("8.0.35", 3),
|
||||||
+ b"\x21" # charset utf8
|
("8.0.36", 3),
|
||||||
+ b"\x02\x00" # status flags
|
("8.0.39", 2),
|
||||||
+ b"\xff\x81" # capability flags high
|
("8.0.40", 1),
|
||||||
+ 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
|
# Monotonic per-process counter for connection IDs. Seeded with a
|
||||||
+ b"mysql_native_password\x00" # auth plugin name
|
# 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("<I", conn_id)
|
||||||
|
+ salt[:8]
|
||||||
|
+ b"\x00"
|
||||||
|
+ b"\xff\xf7"
|
||||||
|
+ b"\x21"
|
||||||
|
+ b"\x02\x00"
|
||||||
|
+ b"\xff\x81"
|
||||||
|
+ b"\x15"
|
||||||
|
+ b"\x00" * 10
|
||||||
|
+ salt[8:] + b"\x00"
|
||||||
|
+ b"mysql_native_password\x00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_packet(payload: bytes, seq: int = 0) -> bytes:
|
def _make_packet(payload: bytes, seq: int = 0) -> bytes:
|
||||||
@@ -54,12 +78,17 @@ class MySQLProtocol(asyncio.Protocol):
|
|||||||
self._peer = None
|
self._peer = None
|
||||||
self._buf = b""
|
self._buf = b""
|
||||||
self._greeted = False
|
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):
|
def connection_made(self, transport):
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
_log("connect", src=self._peer[0], src_port=self._peer[1],
|
||||||
transport.write(_make_packet(_GREETING, seq=0))
|
connection_id=self._conn_id)
|
||||||
|
transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0))
|
||||||
self._greeted = True
|
self._greeted = True
|
||||||
|
|
||||||
def data_received(self, data):
|
def data_received(self, data):
|
||||||
@@ -81,19 +110,24 @@ class MySQLProtocol(asyncio.Protocol):
|
|||||||
if not payload:
|
if not payload:
|
||||||
return
|
return
|
||||||
# Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated)
|
# Login packet: capability flags (4), max_packet (4), charset (1), reserved (23), username (NUL-terminated)
|
||||||
|
username = "<unknown>"
|
||||||
if len(payload) > 32:
|
if len(payload) > 32:
|
||||||
try:
|
try:
|
||||||
# skip capability(4) + max_pkt(4) + charset(1) + reserved(23) = 32 bytes
|
|
||||||
username_start = 32
|
username_start = 32
|
||||||
nul = payload.index(b"\x00", username_start)
|
nul = payload.index(b"\x00", username_start)
|
||||||
username = payload[username_start:nul].decode(errors="replace")
|
username = payload[username_start:nul].decode(errors="replace")
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
username = "<parse_error>"
|
username = "<parse_error>"
|
||||||
_log("auth", src=self._peer[0], username=username)
|
_log("auth", src=self._peer[0], username=username,
|
||||||
# Send Access Denied error
|
connection_id=self._conn_id)
|
||||||
err = b"\xff" + struct.pack("<H", 1045) + b"#28000Access denied for user\x00"
|
# Real mysqld includes client IP in the error text.
|
||||||
self._transport.write(_make_packet(err, seq=2))
|
src_ip = self._peer[0] if self._peer else "?"
|
||||||
self._transport.close()
|
msg = f"Access denied for user '{username}'@'{src_ip}' (using password: YES)"
|
||||||
|
err = b"\xff" + struct.pack("<H", 1045) + b"#28000" + msg.encode()
|
||||||
|
_seed.jitter_sync(15, 90)
|
||||||
|
if self._transport and not self._transport.is_closing():
|
||||||
|
self._transport.write(_make_packet(err, seq=2))
|
||||||
|
self._transport.close()
|
||||||
|
|
||||||
def connection_lost(self, exc):
|
def connection_lost(self, exc):
|
||||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||||
|
|||||||
@@ -9,14 +9,43 @@ returns an error. Logs all interactions as JSON.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
|
NODE_NAME = os.environ.get("NODE_NAME", "pgserver")
|
||||||
SERVICE_NAME = "postgres"
|
SERVICE_NAME = "postgres"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "5432"))
|
PORT = int(os.environ.get("PORT", "5432"))
|
||||||
def _error_response(message: str) -> 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
|
return b"E" + struct.pack(">I", len(body) + 4) + body
|
||||||
|
|
||||||
|
|
||||||
@@ -90,8 +119,18 @@ class PostgresProtocol(asyncio.Protocol):
|
|||||||
if k:
|
if k:
|
||||||
params[k] = v
|
params[k] = v
|
||||||
username = params.get("user", "")
|
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)
|
_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"
|
self._state = "auth"
|
||||||
salt = os.urandom(4)
|
salt = os.urandom(4)
|
||||||
auth_md5 = b"R" + struct.pack(">I", 12) + struct.pack(">I", 5) + salt
|
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):
|
def _handle_password(self, payload: bytes):
|
||||||
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
|
pw_hash = payload.rstrip(b"\x00").decode(errors="replace")
|
||||||
_log("auth", src=self._peer[0], pw_hash=pw_hash)
|
_log("auth", src=self._peer[0], pw_hash=pw_hash,
|
||||||
self._transport.write(_error_response("password authentication failed"))
|
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()
|
self._transport.close()
|
||||||
|
|
||||||
def connection_lost(self, exc):
|
def connection_lost(self, exc):
|
||||||
|
|||||||
@@ -7,38 +7,111 @@ KEYS, and arbitrary commands. Logs every command and argument as JSON.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
|
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
|
||||||
SERVICE_NAME = "redis"
|
SERVICE_NAME = "redis"
|
||||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||||
PORT = int(os.environ.get("PORT", "6379"))
|
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 = (
|
# Per-instance realistic version pick (weighted toward still-supported lines).
|
||||||
f"# Server\n"
|
_REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([
|
||||||
f"redis_version:{_REDIS_VER}\n"
|
("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2),
|
||||||
f"redis_mode:standalone\n"
|
("7.0.15", 2), ("7.0.14", 1),
|
||||||
f"os:{_REDIS_OS}\n"
|
("6.2.14", 2), ("6.2.16", 1),
|
||||||
f"arch_bits:64\n"
|
])
|
||||||
f"tcp_port:6379\n"
|
# Kernel line matching plausible Debian/Ubuntu LTS minor ranges.
|
||||||
f"uptime_in_seconds:864000\n"
|
_REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([
|
||||||
f"connected_clients:1\n"
|
"Linux 5.15.0-118-generic x86_64",
|
||||||
f"# Keyspace\n"
|
"Linux 6.1.0-21-amd64 x86_64",
|
||||||
).encode()
|
"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 = {
|
# AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns
|
||||||
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
# the canonical "Client sent AUTH, but no password is set" error, matching a
|
||||||
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
|
# real redis-server with requirepass unset.
|
||||||
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
|
_REQUIREPASS = os.environ.get("REDIS_PASSWORD", "")
|
||||||
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"}',
|
def _info_block() -> bytes:
|
||||||
b"config:db_password": b"Pr0dDB!2024#Secure",
|
uptime = _seed.uptime_seconds()
|
||||||
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
|
uptime_days = max(1, uptime // 86400)
|
||||||
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
# Minimal but plausible subset; real redis INFO has ~150 keys.
|
||||||
b"rate_limit:192.168.1.1": b"42",
|
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)
|
return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_get(pattern: str) -> bytes:
|
||||||
|
"""Emulate `CONFIG GET <pattern>` — 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):
|
class RedisProtocol(asyncio.Protocol):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._transport = None
|
self._transport = None
|
||||||
self._peer = None
|
self._peer = None
|
||||||
self._parser = RESPParser()
|
self._parser = RESPParser()
|
||||||
|
self._authed = not _REQUIREPASS # auth satisfied iff no password set
|
||||||
|
|
||||||
def connection_made(self, transport):
|
def connection_made(self, transport):
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
@@ -129,6 +213,16 @@ class RedisProtocol(asyncio.Protocol):
|
|||||||
for cmd in self._parser.feed(data):
|
for cmd in self._parser.feed(data):
|
||||||
self._handle_command(cmd)
|
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):
|
def _handle_command(self, parts):
|
||||||
if not parts:
|
if not parts:
|
||||||
return
|
return
|
||||||
@@ -137,15 +231,40 @@ class RedisProtocol(asyncio.Protocol):
|
|||||||
_log("command", src=self._peer[0], cmd=verb, args=args[:8])
|
_log("command", src=self._peer[0], cmd=verb, args=args[:8])
|
||||||
|
|
||||||
if verb == "AUTH":
|
if verb == "AUTH":
|
||||||
password = args[0] if args else ""
|
password = args[-1] if args else ""
|
||||||
_log("auth", src=self._peer[0], password=password)
|
_log("auth", src=self._peer[0], password=password)
|
||||||
self._transport.write(b"+OK\r\n")
|
if not _REQUIREPASS:
|
||||||
elif verb == "INFO":
|
self._write(
|
||||||
self._transport.write(f"${len(_INFO)}\r\n".encode() + _INFO + b"\r\n")
|
_err("Client sent AUTH, but no password is set. "
|
||||||
|
"Did you mean AUTH <username> <password>?")
|
||||||
|
)
|
||||||
|
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":
|
elif verb == "PING":
|
||||||
self._transport.write(b"+PONG\r\n")
|
self._write(b"+PONG\r\n")
|
||||||
elif verb == "CONFIG":
|
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":
|
elif verb == "KEYS":
|
||||||
pattern = args[0] if args else "*"
|
pattern = args[0] if args else "*"
|
||||||
keys = list(_FAKE_STORE.keys())
|
keys = list(_FAKE_STORE.keys())
|
||||||
@@ -157,26 +276,35 @@ class RedisProtocol(asyncio.Protocol):
|
|||||||
keys = [k for k in keys if k == pat]
|
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)
|
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":
|
elif verb == "GET":
|
||||||
key = args[0].encode() if args else b""
|
key = args[0].encode() if args else b""
|
||||||
if key in _FAKE_STORE:
|
if key in _FAKE_STORE:
|
||||||
self._transport.write(_bulk(_FAKE_STORE[key].decode()))
|
self._write(_bulk(_FAKE_STORE[key].decode()))
|
||||||
else:
|
else:
|
||||||
self._transport.write(b"$-1\r\n")
|
self._write(b"$-1\r\n")
|
||||||
elif verb == "SCAN":
|
elif verb == "SCAN":
|
||||||
keys = list(_FAKE_STORE.keys())
|
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)
|
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":
|
elif verb == "TYPE":
|
||||||
self._transport.write(b"+string\r\n")
|
self._write(b"+string\r\n")
|
||||||
elif verb == "TTL":
|
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":
|
elif verb == "QUIT":
|
||||||
self._transport.write(b"+OK\r\n")
|
self._write(b"+OK\r\n")
|
||||||
self._transport.close()
|
if self._transport:
|
||||||
|
self._transport.close()
|
||||||
else:
|
else:
|
||||||
self._transport.write(_err("unknown command"))
|
self._write(_err(f"unknown command '{verb.lower()}'"))
|
||||||
|
|
||||||
def connection_lost(self, exc):
|
def connection_lost(self, exc):
|
||||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ The DATA state machine (and the 502-per-line bug) is fixed in both modes.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import random
|
import random as _rand
|
||||||
import string
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import instance_seed as _seed
|
||||||
from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
|
from syslog_bridge import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
|
||||||
|
|
||||||
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
|
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"))
|
PORT = int(os.environ.get("PORT", "25"))
|
||||||
OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1"
|
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"^<?([^\s<>@]+)@([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_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)")
|
||||||
_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME)
|
_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:
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
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:
|
def _rand_msg_id() -> str:
|
||||||
"""Return a Postfix-style 12-char alphanumeric queue ID."""
|
"""Postfix-style queue ID.
|
||||||
chars = string.ascii_uppercase + string.digits
|
|
||||||
return "".join(random.choices(chars, k=12))
|
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]:
|
def _decode_auth_plain(blob: str) -> tuple[str, str]:
|
||||||
@@ -108,6 +143,9 @@ class SMTPProtocol(asyncio.Protocol):
|
|||||||
rcpt_to=",".join(self._rcpt_to),
|
rcpt_to=",".join(self._rcpt_to),
|
||||||
body_bytes=len(body),
|
body_bytes=len(body),
|
||||||
msg_id=msg_id)
|
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._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
|
||||||
self._in_data = False
|
self._in_data = False
|
||||||
self._data_buf = []
|
self._data_buf = []
|
||||||
@@ -172,9 +210,30 @@ class SMTPProtocol(asyncio.Protocol):
|
|||||||
elif cmd == "RCPT":
|
elif cmd == "RCPT":
|
||||||
addr = args.split(":", 1)[1].strip() if ":" in args else args
|
addr = args.split(":", 1)[1].strip() if ":" in args else args
|
||||||
if OPEN_RELAY:
|
if OPEN_RELAY:
|
||||||
self._rcpt_to.append(addr)
|
match = _ADDR_RE.match(addr)
|
||||||
_log("rcpt_to", src=self._peer[0], value=addr)
|
if not match:
|
||||||
self._transport.write(b"250 2.1.5 Ok\r\n")
|
_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:
|
else:
|
||||||
_log("rcpt_denied", src=self._peer[0], value=addr,
|
_log("rcpt_denied", src=self._peer[0], value=addr,
|
||||||
severity=SEVERITY_WARNING)
|
severity=SEVERITY_WARNING)
|
||||||
@@ -246,7 +305,14 @@ class SMTPProtocol(asyncio.Protocol):
|
|||||||
_log("auth_attempt", src=self._peer[0],
|
_log("auth_attempt", src=self._peer[0],
|
||||||
username=username, password=password,
|
username=username, password=password,
|
||||||
severity=SEVERITY_WARNING)
|
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")
|
self._transport.write(b"235 2.7.0 Authentication successful\r\n")
|
||||||
else:
|
else:
|
||||||
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
|
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user