Files
DECNET/decnet/templates/ldap/server.py
anti 3fb84ac5d0 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.
2026-04-22 09:24:16 -04:00

203 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
LDAPserver.
Parses BER-encoded BindRequest messages, logs DN and password, returns an
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)
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 _ber_length(data: bytes, pos: int):
"""Return (length, next_pos)."""
b = data[pos]
if b < 0x80:
return b, pos + 1
n = b & 0x7f
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
return length, pos + 1 + n
def _ber_string(data: bytes, pos: int):
"""Skip tag byte, read BER length, return (string, next_pos)."""
pos += 1 # skip tag
length, pos = _ber_length(data, pos)
return data[pos:pos + length].decode(errors="replace"), pos + length
def _parse_bind_request(msg: bytes):
"""Best-effort extraction of (dn, password) from a raw LDAPMessage."""
try:
pos = 0
# LDAPMessage SEQUENCE
assert msg[pos] == 0x30 # nosec B101
pos += 1
_, pos = _ber_length(msg, pos)
# messageID INTEGER
assert msg[pos] == 0x02 # nosec B101
pos += 1
id_len, pos = _ber_length(msg, pos)
pos += id_len
# BindRequest [APPLICATION 0]
assert msg[pos] == 0x60 # nosec B101
pos += 1
_, pos = _ber_length(msg, pos)
# version INTEGER
assert msg[pos] == 0x02 # nosec B101
pos += 1
v_len, pos = _ber_length(msg, pos)
pos += v_len
# name LDAPDN (OCTET STRING)
dn, pos = _ber_string(msg, pos)
# authentication CHOICE — simple [0] OCTET STRING
if msg[pos] == 0x80:
pos += 1
pw_len, pos = _ber_length(msg, pos)
password = msg[pos:pos + pw_len].decode(errors="replace")
else:
password = "<sasl_or_unknown>" # nosec B105
return dn, password
except Exception:
return "<parse_error>", "<parse_error>"
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])
ldap_msg_body = msg_id_enc + bind_resp
return bytes([0x30, len(ldap_msg_body)]) + ldap_msg_body
class LDAPProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._buf = b""
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])
def data_received(self, data):
self._buf += data
self._process()
def _process(self):
while len(self._buf) >= 2:
if self._buf[0] != 0x30:
self._buf = b""
return
if self._buf[1] < 0x80:
msg_len = self._buf[1] + 2
elif self._buf[1] == 0x81:
if len(self._buf) < 3:
return
msg_len = self._buf[2] + 3
else:
self._buf = b""
return
if len(self._buf) < msg_len:
return
msg = self._buf[:msg_len]
self._buf = self._buf[msg_len:]
self._handle_message(msg)
def _handle_message(self, msg: bytes):
# Extract messageID for the response
try:
message_id = msg[4] if len(msg) > 4 else 1
except Exception:
message_id = 1
dn, password = _parse_bind_request(msg)
_log("bind", src=self._peer[0], dn=dn, password=password)
_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 "?")
async def main():
_log("startup", msg=f"LDAP server starting as {NODE_NAME}")
loop = asyncio.get_running_loop()
server = await loop.create_server(LDAPProtocol, "0.0.0.0", 389) # nosec B104
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())