# SPDX-License-Identifier: GPL-2.0-or-later
# This file is part of Scapy
# See https://scapy.net/ for more information
# Copyright (C) Gabriel Potter

"""
DCE/RPC server as per [MS-RPCE]
"""

import socket
import uuid
import threading
from collections import deque

from scapy.arch import get_if_addr
from scapy.config import conf
from scapy.data import MTU
from scapy.volatile import RandShort

from scapy.layers.dcerpc import (
    CommonAuthVerifier,
    DCE_RPC_INTERFACES,
    DCERPC_Transport,
    DceRpc5,
    DceRpc5AlterContext,
    DceRpc5AlterContextResp,
    DceRpc5Auth3,
    DceRpc5Bind,
    DceRpc5BindAck,
    DceRpc5BindNak,
    DceRpc5Fault,
    DceRpc5PortAny,
    DceRpc5Request,
    DceRpc5Response,
    DceRpc5Result,
    DceRpc5TransferSyntax,
    DceRpcInterface,
    DceRpcSession,
    NDRPacket,
    RPC_C_AUTHN_LEVEL,
)

# RPC
from scapy.layers.msrpce.ept import (
    ept_map_Request,
    ept_map_Response,
    twr_p_t,
    protocol_tower_t,
    prot_and_addr_t,
)

# Typing
from typing import (
    Dict,
    Callable,
    Optional,
    Tuple,
)


class _DCERPC_Server_metaclass(type):
    # This value is calculated for each DCE/RPC server, and contains
    # the callables sorted by interface+opnum
    dcerpc_commands: Dict[Tuple[uuid.UUID, int], Callable] = {}

    def __new__(cls, name, bases, dct):
        dct.setdefault(
            "dcerpc_commands",
            {x.dcerpc_command: x for x in dct.values() if hasattr(x, "dcerpc_command")},
        )
        return type.__new__(cls, name, bases, dct)


class DCERPC_Server(metaclass=_DCERPC_Server_metaclass):
    def __init__(
        self,
        transport: DCERPC_Transport,
        ndr64: Optional[bool] = None,
        verb: bool = True,
        local_ip: str = None,
        port: int = None,
        portmap: Dict[DceRpcInterface, int] = None,
        **kwargs,
    ):
        self.transport = transport
        self.session = DceRpcSession(**kwargs)
        self.queue = deque()
        if ndr64 is None:
            ndr64 = conf.ndr64
        self.ndr64 = ndr64
        # For endpoint mapper. TODO: improve separation/handling of SMB/IP etc
        self.local_ip = local_ip
        self.port = port
        self.portmap = portmap or {}
        self.verb = verb

    def loop(self, sock):
        while True:
            pkt = sock.recv(MTU)
            if not pkt:
                break
            self.recv(pkt)
            # send all possible responses
            while True:
                resp = self.get_response()
                if not resp:
                    break
                sock.send(bytes(resp))

    @staticmethod
    def answer(reqcls):
        """
        A decorator that registers a DCE/RPC responder to a command.
        See the DCE/RPC documentation.

        :param reqcls: the DCE/RPC packet class to respond to
        """

        def deco(func):
            if not issubclass(reqcls, NDRPacket):
                raise ValueError("Cannot answer a non NDRPacket class !")
            try:
                func.dcerpc_command = reqcls.intf, reqcls.opnum
            except AttributeError:
                raise ValueError(
                    "NDRPacket class isn't registered or isn't a request !"
                )
            return func

        return deco

    def extend(self, server_cls):
        """
        Extend a DCE/RPC server into another
        """
        self.dcerpc_commands.update(server_cls.dcerpc_commands)

    def make_reply(self, req):
        """
        Make a response to the DCE/RPC request.

        This finds whether a callback has been registered for this particular packet,
        and call it if available.
        """
        opnum = req[DceRpc5Request].opnum
        intf = self.session.rpc_bind_interface.uuid
        if (intf, opnum) in self.dcerpc_commands:
            # call handler
            return self.dcerpc_commands[(intf, opnum)](self, req)
        return None

    @classmethod
    def spawn(cls, transport, iface=None, port=135, bg=False, **kwargs):
        """
        Spawn a DCE/RPC server

        :param transport: one of DCERPC_Transport
        :param iface: the interface to spawn it on (default: conf.iface)
        :param port: the port to spawn it on (for IP_TCP or the SMB server)
        :param bg: background mode? (default: False)
        :param ndr64: whether NDR64 is supported or not (default: conf.ndr64).
            This attribute will be overwritten if the client doesn't support it.
        """
        if transport == DCERPC_Transport.NCACN_IP_TCP:
            # IP/TCP case
            ssock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            local_ip = get_if_addr(iface or conf.iface)
            try:
                ssock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            except OSError:
                pass
            ssock.bind((local_ip, port))
            ssock.listen(5)
            sockets = []
            if kwargs.get("verb", True):
                print(
                    conf.color_theme.green(
                        "Server %s started. Waiting..." % cls.__name__
                    )
                )

            def _run():
                # Wait for clients forever
                try:
                    while True:
                        clientsocket, address = ssock.accept()
                        sockets.append(clientsocket)
                        print(
                            conf.color_theme.gold(
                                "\u2503 Connection received from %s" % repr(address)
                            )
                        )
                        server = cls(
                            DCERPC_Transport.NCACN_IP_TCP,
                            local_ip=local_ip,
                            port=port,
                            **kwargs,
                        )
                        threading.Thread(
                            target=server.loop, args=(clientsocket,)
                        ).start()
                except KeyboardInterrupt:
                    print("X Exiting.")
                    ssock.shutdown(socket.SHUT_RDWR)
                except OSError:
                    print("X Server closed.")
                finally:
                    for sock in sockets:
                        try:
                            sock.shutdown(socket.SHUT_RDWR)
                            sock.close()
                        except Exception:
                            pass
                    ssock.close()

            if bg:
                # Background
                threading.Thread(target=_run).start()
                return ssock
            else:
                # Non-background
                _run()
        elif transport == DCERPC_Transport.NCACN_NP:
            # SMB case
            from scapy.layers.smbserver import SMB_Server

            kwargs.setdefault("shares", [])  # do not expose files by default
            return SMB_Server.spawn(
                iface=iface or conf.iface,
                port=port,
                bg=bg,
                # Important: pass the DCE/RPC server
                DCERPC_SERVER_CLS=cls,
                # SMB parameters
                **kwargs,
            )
        else:
            raise ValueError("Unsupported transport :(")

    def recv(self, data):
        if isinstance(data, bytes):
            req = DceRpc5(data)
        else:
            req = data
        # If the packet has padding, it contains several fragments
        pad = None
        if conf.padding_layer in req:
            pad = req[conf.padding_layer].load
            req[conf.padding_layer].underlayer.remove_payload()
        # Ask the DCE/RPC session to process it (match interface, etc.)
        req = self.session.in_pkt(req)
        hdr = DceRpc5(
            endian=req.endian,
            encoding=req.encoding,
            float=req.float,
            call_id=req.call_id,
        )
        # Now process the packet based on the DCE/RPC type
        if DceRpc5Bind in req or DceRpc5AlterContext in req or DceRpc5Auth3 in req:
            # Log
            if self.verb:
                print(
                    conf.color_theme.opening(
                        "<< %s" % req.payload.__class__.__name__
                        + (
                            " (with %s%s)"
                            % (
                                self.session.ssp.__class__.__name__,
                                (
                                    f" - {self.session.auth_level.name}"
                                    if self.session.auth_level is not None
                                    else ""
                                ),
                            )
                            if self.session.ssp
                            else ""
                        )
                    )
                )
            if not self.session.rpc_bind_interface:
                # The session did not find a matching interface !
                self.queue.extend(self.session.out_pkt(hdr / DceRpc5BindNak()))
                if self.verb:
                    print(conf.color_theme.fail("! DceRpc5BindNak (unknown interface)"))
            else:
                auth_value, status = None, 0
                if (
                    self.session.ssp
                    and req.auth_verifier
                    and req.auth_verifier.auth_value
                ):
                    (
                        self.session.sspcontext,
                        auth_value,
                        status,
                    ) = self.session.ssp.GSS_Accept_sec_context(
                        self.session.sspcontext, req.auth_verifier.auth_value
                    )
                    self.session.auth_level = RPC_C_AUTHN_LEVEL(
                        req.auth_verifier.auth_level
                    )
                    self.session.auth_context_id = req.auth_verifier.auth_context_id
                    if DceRpc5Auth3 in req:
                        # Auth 3 stops here (no server response) !
                        if status != 0:
                            print(conf.color_theme.fail("! DceRpc5Auth3 failed"))
                        if pad is not None:
                            self.recv(pad)
                        return
                    # auth_verifier here contains the SSP nego packets
                    # (whereas it usually contains the verifiers)
                    if auth_value is not None:
                        hdr.auth_verifier = CommonAuthVerifier(
                            auth_type=req.auth_verifier.auth_type,
                            auth_level=req.auth_verifier.auth_level,
                            auth_context_id=req.auth_verifier.auth_context_id,
                            auth_value=auth_value,
                        )

                # Detect if the client requested NDR64 and the server agrees
                self.ndr64 = self.ndr64 and any(
                    ctx.transfer_syntaxes[0].sprintf("%if_uuid%") == "NDR64"
                    for ctx in req.context_elem
                )

                # Process bind contexts and answer to them
                results = []
                for ctx in req.context_elem:
                    # Get name
                    name = ctx.transfer_syntaxes[0].sprintf("%if_uuid%")
                    if (
                        # NDR64
                        (name == "NDR64" and self.ndr64)
                        or
                        # NDR 2.0
                        (name == "NDR 2.0" and not self.ndr64)
                    ):
                        # Acceptance
                        results.append(
                            DceRpc5Result(
                                result=0,
                                reason=0,
                                transfer_syntax=DceRpc5TransferSyntax(
                                    if_uuid=ctx.transfer_syntaxes[0].if_uuid,
                                    if_version=ctx.transfer_syntaxes[0].if_version,
                                ),
                            )
                        )
                    elif name == "Bind Time Feature Negotiation":
                        # Handle Bind Time Feature
                        results.append(
                            DceRpc5Result(
                                result=3,
                                reason=3,
                                transfer_syntax=DceRpc5TransferSyntax(
                                    if_uuid="NULL",
                                    if_version=0,
                                ),
                            )
                        )
                    else:
                        # Reject
                        results.append(
                            DceRpc5Result(
                                result=2,
                                reason=2,
                                transfer_syntax=DceRpc5TransferSyntax(
                                    if_uuid="NULL",
                                    if_version=0,
                                ),
                            )
                        )

                if self.port is None:
                    # Piped
                    port_spec = (
                        b"\\\\PIPE\\\\%s\0"
                        % self.session.rpc_bind_interface.name.encode()
                    )
                else:
                    # IP
                    port_spec = str(self.port).encode() + b"\x00"
                if DceRpc5Bind in req:
                    cls = DceRpc5BindAck
                else:
                    cls = DceRpc5AlterContextResp
                self.queue.extend(
                    self.session.out_pkt(
                        hdr
                        / cls(
                            assoc_group_id=int(RandShort()),
                            sec_addr=DceRpc5PortAny(
                                port_spec=port_spec,
                            ),
                            results=results,
                        ),
                    )
                )
                if self.verb:
                    print(
                        conf.color_theme.success(
                            f">> {cls.__name__} {self.session.rpc_bind_interface.name}"
                            f" is on port '{port_spec.decode()}' using "
                            + ("NDR64" if self.ndr64 else "NDR32")
                        )
                    )
        elif DceRpc5Request in req:
            if self.verb:
                print(
                    conf.color_theme.opening(
                        "<< REQUEST: %s"
                        % req[DceRpc5Request].payload.__class__.__name__
                    )
                )
            # Can be any RPC request !
            resp = self.make_reply(req)
            if resp:
                self.queue.extend(
                    self.session.out_pkt(
                        hdr
                        / DceRpc5Response(
                            alloc_hint=len(resp),
                            cont_id=req.cont_id,
                        )
                        / resp,
                    )
                )
                if self.verb:
                    print(
                        conf.color_theme.success(
                            ">> RESPONSE: %s" % (resp.__class__.__name__)
                        )
                    )
            else:
                # Unimplemented request !
                if self.verb:
                    print(
                        conf.color_theme.fail(
                            "! RPC request not implemented by server."
                        )
                    )
                    req.show()

                # Return a Fault
                hdr.pfc_flags += "PFC_DID_NOT_EXECUTE"
                self.queue.extend(
                    hdr
                    / DceRpc5Fault(
                        # nca_s_op_rng_error
                        status=0x1C010002,
                        cont_id=req.cont_id,
                    )
                )
        # If there was padding, process the second frag
        if pad is not None:
            self.recv(pad)

    def get_response(self):
        try:
            return self.queue.popleft()
        except IndexError:
            return None

    # Endpoint mapper

    @answer.__func__(ept_map_Request)  # hack for Python <= 3.9
    def ept_map(self, req):
        """
        Answer to ept_map_Request.
        """
        if self.transport != DCERPC_Transport.NCACN_IP_TCP:
            raise ValueError("Unimplemented")

        tower = protocol_tower_t(
            req[ept_map_Request].valueof("map_tower.tower_octet_string")
        )
        uuid = tower.floors[0].uuid
        if_version = (tower.floors[0].rhs << 16) | tower.floors[0].version

        # Check for results in our portmap
        port = None
        if (uuid, if_version) in DCE_RPC_INTERFACES:
            interface = DCE_RPC_INTERFACES[(uuid, if_version)]
            if interface in self.portmap:
                port = self.portmap[interface]

        if port is not None:
            # Found result
            resp_tower = twr_p_t(
                tower_octet_string=bytes(
                    protocol_tower_t(
                        floors=[
                            tower.floors[0],  # UUID
                            tower.floors[1],  # NDR version
                            tower.floors[2],  # RPC version
                            prot_and_addr_t(
                                lhs_length=1,
                                protocol_identifier="NCACN_IP_TCP",
                                rhs_length=2,
                                rhs=port,
                            ),
                            prot_and_addr_t(
                                lhs_length=1,
                                protocol_identifier="IP",
                                rhs_length=4,
                                rhs=self.local_ip or "0.0.0.0",
                            ),
                        ]
                    )
                )
            )
            resp = ept_map_Response(ITowers=[resp_tower], ndr64=self.ndr64)
            resp.ITowers.max_count = req.max_towers  # ugh
        else:
            # No result found
            pass
        return resp
