#!/usr/bin/env python3
"""Minimal honeypot RDP server.

Two operating modes share the same X.224 Connection Request parser:

1. **Default (basic).** Parse the X.224 CR, extract the ``mstshash``
   routing cookie + ``rdpNegRequest.requestedProtocols`` flags, answer
   with a Connection Confirm selecting ``PROTOCOL_RDP``, close.
   Captures the username most attackers leak in plaintext.

2. **NLA (``RDP_ENABLE_NLA=true``).** Confirm ``PROTOCOL_HYBRID``,
   upgrade the socket to TLS, then read inbound CredSSP TSRequest DER
   blobs. We do not parse the ASN.1 — we just scan for the NTLMSSP
   signature inside the TLS-decrypted plaintext (CredSSP wraps a
   handful of NTLMSSP messages); when the inbound message is a
   Type 3, ``parse_type3()`` produces the universal credential SD
   block and we land an NTLMv2 hash in the Credential table. The
   server responds to Type 1 with a hand-built TSRequest carrying an
   NTLMSSP Type 2 challenge, then drops after Type 3.

References:
- MS-RDPBCGR §2.2.1.1 Client X.224 Connection Request PDU
- MS-RDPBCGR §2.2.1.2 Server X.224 Connection Confirm PDU
- MS-CSSP §2.2.1 TSRequest
- MS-NLMP §2.2.1.2 NTLMSSP CHALLENGE_MESSAGE
- RFC 1006 (TPKT) §6
"""

from __future__ import annotations

import asyncio
import os
import re
import ssl
import struct

import instance_seed
from ntlmssp import find_ntlmssp, parse_type3
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog

NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION")
SERVICE_NAME = "rdp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
ENABLE_NLA = os.environ.get("RDP_ENABLE_NLA", "").lower() in ("1", "true", "yes")
TLS_CERT = os.environ.get("TLS_CERT", "/opt/tls/cert.pem")
TLS_KEY = os.environ.get("TLS_KEY", "/opt/tls/key.pem")

LISTEN_HOST = "0.0.0.0"  # nosec B104 — honeypot binds all interfaces by design
LISTEN_PORT = 3389

# Per-instance NTLM challenge: deterministic-per-decky-but-different-
# across-the-fleet (see instance_seed module docstring). A fixed
# challenge across the fleet would let scanners fingerprint us.
SERVER_CHALLENGE = instance_seed.random_bytes(8, "ntlm_challenge")

MAX_TSREQUEST_LEN = 32 * 1024  # CredSSP messages are small; cap memory pressure

# X.224 / TPKT constants
TPKT_VERSION = 0x03
X224_CR = 0xE0  # Connection Request
X224_CC = 0xD0  # Connection Confirm

# rdpNegRequest / Response (MS-RDPBCGR §2.2.1.1.1 / §2.2.1.2.1)
TYPE_RDP_NEG_REQ = 0x01
TYPE_RDP_NEG_RSP = 0x02

PROTOCOL_RDP = 0x00000000
PROTOCOL_SSL = 0x00000001
PROTOCOL_HYBRID = 0x00000002

MAX_TPKT_LEN = 8 * 1024  # CR PDUs are tiny; cap to avoid attacker memory pressure

_COOKIE_RE = re.compile(rb"Cookie:\s*mstshash=([^\r\n\x00]{1,256})\r\n", re.IGNORECASE)


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)


# ── PDU helpers ───────────────────────────────────────────────────────────────


def _parse_tpkt(buf: bytes) -> bytes | None:
    """Return the X.224 payload from a single TPKT, or None if malformed."""
    if len(buf) < 4 or buf[0] != TPKT_VERSION:
        return None
    total_len = int.from_bytes(buf[2:4], "big")
    if total_len < 7 or total_len > MAX_TPKT_LEN or total_len > len(buf):
        return None
    return buf[4:total_len]


def _parse_x224_cr(x224: bytes) -> tuple[str | None, int]:
    """Return (mstshash_cookie, requested_protocols).

    Cookie is None when absent. requested_protocols is 0 when no
    rdpNegRequest is included.
    """
    if len(x224) < 7 or x224[1] != X224_CR:
        return None, 0
    # x224[0] = LI (length indicator), x224[1] = CR code (TPDU type)
    # Variable part follows the fixed 7-byte header. Cookie is ASCII
    # text terminated by CRLF; rdpNegRequest is the next 8 bytes.
    var = x224[7:]
    cookie_match = _COOKIE_RE.search(var)
    cookie = None
    if cookie_match:
        try:
            cookie = cookie_match.group(1).decode("ascii", errors="replace")
        except Exception:  # noqa: BLE001
            cookie = None
    # rdpNegRequest sits after the cookie's CRLF. Locate by signature
    # rather than offset since cookie length varies.
    requested = 0
    neg = var
    if cookie_match:
        neg = var[cookie_match.end():]
    if len(neg) >= 8 and neg[0] == TYPE_RDP_NEG_REQ:
        # Type(1) Flags(1) Length(2 LE) RequestedProtocols(4 LE)
        requested = int.from_bytes(neg[4:8], "little")
    return cookie, requested


def _build_x224_cc(selected_protocol: int = PROTOCOL_RDP) -> bytes:
    """Build a TPKT-wrapped X.224 Connection Confirm with rdpNegRsp."""
    # rdpNegResponse: Type(1)=0x02 Flags(1)=0x00 Length(2 LE)=0x0008
    #                 SelectedProtocol(4 LE)
    neg_rsp = bytes([TYPE_RDP_NEG_RSP, 0x00]) + (8).to_bytes(2, "little") + selected_protocol.to_bytes(4, "little")
    # X.224 CC fixed header: LI=0x0E (14 bytes follow), CC=0xD0,
    # DST_REF=0, SRC_REF=0x1234 (any), CLASS=0x00
    x224 = bytes([0x0E, X224_CC, 0x00, 0x00, 0x12, 0x34, 0x00]) + neg_rsp
    tpkt = bytes([TPKT_VERSION, 0x00]) + (4 + len(x224)).to_bytes(2, "big")
    return tpkt + x224


# ── NLA / CredSSP helpers ────────────────────────────────────────────────────


def _der_len(n: int) -> bytes:
    if n < 0x80:
        return bytes([n])
    body = n.to_bytes((n.bit_length() + 7) // 8, "big")
    return bytes([0x80 | len(body)]) + body


def _der_read_len(buf: bytes, off: int) -> tuple[int, int]:
    """Return (length, new_offset) reading a DER length field."""
    if off >= len(buf):
        return 0, off
    first = buf[off]
    off += 1
    if first < 0x80:
        return first, off
    n = first & 0x7F
    if n == 0 or off + n > len(buf):
        return 0, off
    val = int.from_bytes(buf[off:off + n], "big")
    return val, off + n


def _build_ntlmssp_type2(challenge: bytes) -> bytes:
    """Build a minimal NTLMSSP CHALLENGE_MESSAGE (MS-NLMP §2.2.1.2).

    Mirrors the SMB framer's builder. Inlined here rather than shared so
    that ``_shared/ntlmssp.py`` stays a pure parser module.
    """
    target = "WORKGROUP".encode("utf-16-le")
    av_name = "WORKGROUP".encode("utf-16-le")
    target_info = struct.pack("<HH", 1, len(av_name)) + av_name + struct.pack("<HH", 0, 0)
    flags = 0x00828201  # UNICODE | NTLM | TARGET_INFO | always_sign
    target_off = 56
    info_off = target_off + len(target)
    return (
        b"NTLMSSP\x00"
        + struct.pack("<I", 2)
        + struct.pack("<HHI", len(target), len(target), target_off)
        + struct.pack("<I", flags)
        + challenge
        + b"\x00" * 8
        + struct.pack("<HHI", len(target_info), len(target_info), info_off)
        + b"\x00" * 8
        + target + target_info
    )


def _build_tsrequest_with_token(version: int, ntlm_blob: bytes) -> bytes:
    """Build a CredSSP TSRequest carrying a single negoToken (MS-CSSP §2.2.1).

    Layout (DER, simplified — only fields we need on the response path):

        TSRequest ::= SEQUENCE {
            version    [0] INTEGER,
            negoTokens [1] SEQUENCE OF SEQUENCE { negoToken [0] OCTET STRING }
        }
    """
    # version [0] INTEGER
    version_bytes = version.to_bytes(1, "big")
    version_field = b"\x02" + _der_len(len(version_bytes)) + version_bytes
    version_tagged = b"\xa0" + _der_len(len(version_field)) + version_field

    # innermost: negoToken [0] OCTET STRING
    octet = b"\x04" + _der_len(len(ntlm_blob)) + ntlm_blob
    negotoken_tagged = b"\xa0" + _der_len(len(octet)) + octet
    inner_seq = b"\x30" + _der_len(len(negotoken_tagged)) + negotoken_tagged
    outer_seq = b"\x30" + _der_len(len(inner_seq)) + inner_seq
    negotokens_tagged = b"\xa1" + _der_len(len(outer_seq)) + outer_seq

    body = version_tagged + negotokens_tagged
    return b"\x30" + _der_len(len(body)) + body


async def _read_one_tsrequest(reader: asyncio.StreamReader) -> bytes:
    """Read one DER-encoded TSRequest (outer SEQUENCE) from the stream.

    A SEQUENCE starts with tag 0x30 followed by a DER length, then that
    many content bytes. We bound the total to MAX_TSREQUEST_LEN.
    """
    tag = await reader.readexactly(1)
    if tag != b"\x30":
        raise ValueError("not a SEQUENCE")
    first_len = (await reader.readexactly(1))[0]
    if first_len < 0x80:
        body_len = first_len
        len_bytes = bytes([first_len])
    else:
        n = first_len & 0x7F
        if n == 0 or n > 4:
            raise ValueError("bad DER length")
        ext = await reader.readexactly(n)
        body_len = int.from_bytes(ext, "big")
        len_bytes = bytes([first_len]) + ext
    if body_len > MAX_TSREQUEST_LEN:
        raise ValueError("TSRequest too large")
    body = await reader.readexactly(body_len)
    return tag + len_bytes + body


async def _handle_nla(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    src_ip: str,
    src_port: int,
) -> None:
    """Drive the CredSSP exchange post-TLS-handshake.

    Reads up to 3 inbound TSRequests; on the one carrying an NTLMSSP
    Type 3, emits the credential and closes.
    """
    for round_no in range(3):
        try:
            ts_blob = await asyncio.wait_for(_read_one_tsrequest(reader), timeout=10.0)
        except (asyncio.IncompleteReadError, asyncio.TimeoutError, ValueError):
            return
        off = find_ntlmssp(ts_blob)
        if off < 0:
            return
        ntlm = ts_blob[off:]
        # Message type at offset 8 (after the 8-byte signature)
        if len(ntlm) < 12:
            return
        msg_type = struct.unpack_from("<I", ntlm, 8)[0]
        if msg_type == 1:
            # Type 1 → respond with TSRequest carrying Type 2 challenge
            type2 = _build_ntlmssp_type2(SERVER_CHALLENGE)
            resp = _build_tsrequest_with_token(version=6, ntlm_blob=type2)
            writer.write(resp)
            await writer.drain()
            continue
        if msg_type == 3:
            # Type 3 → credential lands here
            cred = parse_type3(ntlm)
            if cred:
                _log(
                    "auth_attempt",
                    src_ip=src_ip,
                    src_port=src_port,
                    auth_path="nla",
                    **cred,
                )
            return
        # Unknown type → drop
        return


# ── Connection handler ───────────────────────────────────────────────────────


def _build_tls_context() -> ssl.SSLContext | None:
    """Load the per-decky self-signed cert for the NLA path.

    Returns None if the cert files aren't present yet (allows the
    container to come up even before the entrypoint has generated
    them; subsequent connections retry).
    """
    if not (os.path.exists(TLS_CERT) and os.path.exists(TLS_KEY)):
        return None
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(certfile=TLS_CERT, keyfile=TLS_KEY)
    # CredSSP clients negotiate down — accept whatever the client offers
    ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
    return ctx


async def _upgrade_to_tls_and_capture(
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
    src_ip: str,
    src_port: int,
) -> None:
    """Upgrade the underlying socket to TLS, then run the CredSSP loop."""
    ctx = _build_tls_context()
    if ctx is None:
        _log("error", severity=4, src_ip=src_ip, msg="TLS cert missing; NLA path unavailable")
        return
    transport = writer.transport
    loop = asyncio.get_running_loop()
    try:
        new_transport = await loop.start_tls(
            transport,
            transport.get_protocol(),
            ctx,
            server_side=True,
        )
    except (ssl.SSLError, OSError) as exc:
        _log("tls_handshake_failed", severity=4, src_ip=src_ip, msg=str(exc))
        return
    # Rewrap the StreamReader/StreamWriter on top of the new TLS transport.
    # We use the stdlib's protocol to bridge the upgraded transport back
    # into a StreamReader/StreamWriter pair the rest of the handler can use.
    new_reader = asyncio.StreamReader(loop=loop)
    new_protocol = asyncio.StreamReaderProtocol(new_reader, loop=loop)
    assert new_transport is not None
    new_transport.set_protocol(new_protocol)
    new_protocol.connection_made(new_transport)
    new_writer = asyncio.StreamWriter(new_transport, new_protocol, new_reader, loop)
    await _handle_nla(new_reader, new_writer, src_ip, src_port)


async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    peer = writer.get_extra_info("peername") or ("?", 0)
    src_ip, src_port = peer[0], peer[1]
    _log("connection", src_ip=src_ip, src_port=src_port)
    try:
        # Read TPKT header (4 bytes), then the rest of the PDU
        hdr = await asyncio.wait_for(reader.readexactly(4), timeout=5.0)
        if hdr[0] != TPKT_VERSION:
            return
        total_len = int.from_bytes(hdr[2:4], "big")
        if total_len < 7 or total_len > MAX_TPKT_LEN:
            return
        rest = await asyncio.wait_for(reader.readexactly(total_len - 4), timeout=5.0)
        x224 = _parse_tpkt(hdr + rest)
        if x224 is None:
            return
        cookie, requested = _parse_x224_cr(x224)
        fields: dict = {
            "src_ip": src_ip,
            "src_port": src_port,
            "requested_protocols": requested,
        }
        if cookie:
            fields["username"] = cookie
            fields["principal"] = cookie
            _log("rdp_cookie", **fields)
        else:
            _log("connection_request", **fields)

        nla_path = ENABLE_NLA and (requested & PROTOCOL_HYBRID)
        selected = PROTOCOL_HYBRID if nla_path else PROTOCOL_RDP
        writer.write(_build_x224_cc(selected))
        await writer.drain()
        if nla_path:
            await _upgrade_to_tls_and_capture(reader, writer, src_ip, src_port)
    except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
        pass
    except Exception as exc:  # noqa: BLE001 — honeypot must never crash the worker
        _log("error", severity=4, src_ip=src_ip, msg=str(exc))
    finally:
        try:
            writer.close()
            await writer.wait_closed()
        except Exception:
            pass
        _log("disconnect", src_ip=src_ip, src_port=src_port)


async def _main() -> None:
    _log("startup", msg=f"RDP server starting as {NODE_NAME} on port {LISTEN_PORT}")
    server = await asyncio.start_server(_handle_client, LISTEN_HOST, LISTEN_PORT)
    async with server:
        await server.serve_forever()


if __name__ == "__main__":
    try:
        asyncio.run(_main())
    except KeyboardInterrupt:
        _log("shutdown")
