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:
2026-04-22 09:24:16 -04:00
parent 51e9e263ca
commit 3fb84ac5d0
12 changed files with 755 additions and 156 deletions

View File

@@ -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 "<parse_error>", "<parse_error>"
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 "?")