current commit
260524.19 · quic: add block list support for endpoints
#pragma once

#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

#include <aliased_struct.h>
#include <async_wrap.h>
#include <env.h>
#include <node_sockaddr.h>
#include <timer_wrap.h>
#include <uv.h>
#include <v8.h>
#include <algorithm>
#include <optional>
#include "arena.h"
#include "bindingdata.h"
#include "packet.h"
#include "session.h"
#include "session_manager.h"
#include "sessionticket.h"
#include "tokens.h"

namespace node::quic {

// An Endpoint encapsulates the UDP local port binding and is responsible for
// sending and receiving QUIC packets. A single endpoint can act as both a QUIC
// client and server simultaneously.
class Endpoint final : public AsyncWrap, public Packet::Listener {
 public:
  // The socket address LRU is used for tracking validated remote addresses.
  static constexpr uint64_t DEFAULT_MAX_SOCKETADDRESS_LRU_SIZE = 1024;

  // Default rate limits for stateless responses. These are global token
  // bucket limits that cap the total rate of each response type regardless
  // of source address. This prevents spoofed-source floods from bypassing
  // per-host limits (which are keyed by source IP and trivially defeated
  // by rotating spoofed addresses). The rate is in responses per second
  // and the burst is the maximum tokens the bucket can hold.
  static constexpr double DEFAULT_RETRY_RATE = 100;
  static constexpr double DEFAULT_RETRY_BURST = 200;
  static constexpr double DEFAULT_STATELESS_RESET_RATE = 100;
  static constexpr double DEFAULT_STATELESS_RESET_BURST = 200;
  static constexpr double DEFAULT_VERSION_NEGOTIATION_RATE = 100;
  static constexpr double DEFAULT_VERSION_NEGOTIATION_BURST = 200;
  static constexpr double DEFAULT_IMMEDIATE_CLOSE_RATE = 100;
  static constexpr double DEFAULT_IMMEDIATE_CLOSE_BURST = 200;

  // Per-host session creation rate limit. This is tracked per validated
  // remote address in the address LRU, preventing a single source from
  // churning through sessions faster than the server can handle. Unlike
  // the global stateless response buckets, this only applies after address
  // validation (spoofed sources can't reach this path).
  static constexpr double DEFAULT_SESSION_CREATION_RATE = 50;
  static constexpr double DEFAULT_SESSION_CREATION_BURST = 100;

  // Endpoint configuration options
  struct Options final : public MemoryRetainer {
    // The local socket address to which the UDP port will be bound. The port
    // may be 0 to have Node.js select an available port. IPv6 or IPv4 addresses
    // may be used. When using IPv6, dual mode will be supported by default.
    std::shared_ptr<SocketAddress> local_address;

    // Retry tokens issued by the Endpoint are time-limited. By default, retry
    // tokens expire after DEFAULT_RETRYTOKEN_EXPIRATION *seconds*. This is an
    // arbitrary choice that is not mandated by the QUIC specification; so we
    // can choose any value that makes sense here. Retry tokens are sent to the
    // client, which echoes them back to the server in a subsequent set of
    // packets, which means the expiration must be set high enough to allow a
    // reasonable round-trip time for the session TLS handshake to complete.
    uint64_t retry_token_expiration =
        RetryToken::QUIC_DEFAULT_RETRYTOKEN_EXPIRATION / NGTCP2_SECONDS;

    // Tokens issued using NEW_TOKEN are time-limited. By default, tokens expire
    // after QUIC_DEFAULT_REGULARTOKEN_EXPIRATION *seconds*.
    uint64_t token_expiration =
        RegularToken::QUIC_DEFAULT_REGULARTOKEN_EXPIRATION / NGTCP2_SECONDS;

    // For tracking the path verification status of remote hosts, we maintain
    // an LRU cache of the most recently seen hosts.
    uint64_t address_lru_size = DEFAULT_MAX_SOCKETADDRESS_LRU_SIZE;

    // Global token bucket rate limits for stateless responses. These cap
    // the total rate of each response type regardless of source address,
    // preventing spoofed-source floods. Rate is in responses per second,
    // burst is the maximum number of responses that can be sent in a burst.
    double retry_rate = DEFAULT_RETRY_RATE;
    double retry_burst = DEFAULT_RETRY_BURST;
    double stateless_reset_rate = DEFAULT_STATELESS_RESET_RATE;
    double stateless_reset_burst = DEFAULT_STATELESS_RESET_BURST;
    double version_negotiation_rate = DEFAULT_VERSION_NEGOTIATION_RATE;
    double version_negotiation_burst = DEFAULT_VERSION_NEGOTIATION_BURST;
    double immediate_close_rate = DEFAULT_IMMEDIATE_CLOSE_RATE;
    double immediate_close_burst = DEFAULT_IMMEDIATE_CLOSE_BURST;

    // Per-host session creation rate limit. Tracked per validated remote
    // address in the address LRU. Set to high values for benchmarking
    // where traffic comes from a single source.
    double session_creation_rate = DEFAULT_SESSION_CREATION_RATE;
    double session_creation_burst = DEFAULT_SESSION_CREATION_BURST;

    // The validate_address parameter instructs the Endpoint to perform explicit
    // address validation using retry tokens. This is strongly recommended and
    // should only be disabled in trusted, closed environments as a performance
    // optimization.
    bool validate_address = true;

    // The stateless reset mechanism can be disabled. This should rarely ever be
    // needed, and should only ever be done in trusted, closed environments as a
    // performance optimization.
    bool disable_stateless_reset = false;

#ifdef DEBUG
    // The rx_loss and tx_loss parameters are debugging tools that allow the
    // Endpoint to simulate random packet loss. The value for each parameter is
    // a value between 0.0 and 1.0 indicating a probability of packet loss. Each
    // time a packet is sent or received, the packet loss bit is calculated and
    // if true, the packet is silently dropped. This should only ever be used
    // for testing and debugging. There is never a reason why rx_loss and
    // tx_loss should ever be used in a production system.
    double rx_loss = 0.0;
    double tx_loss = 0.0;
#endif  // DEBUG

    // By default, when the endpoint is created, it will generate a
    // reset_token_secret at random. This is a secret used in generating
    // stateless reset tokens. In order for stateless reset to be effective,
    // however, it is necessary to use a deterministic secret that persists
    // across ngtcp2 endpoints and sessions. This means that the endpoint
    // configuration really should have a reset token secret passed in.
    TokenSecret reset_token_secret;

    // The secret used for generating new regular tokens.
    TokenSecret token_secret;

    // When the local_address specifies an IPv6 local address to bind to, the
    // ipv6_only parameter determines whether dual stack mode (supporting both
    // IPv6 and IPv4) transparently is supported. This sets the UV_UDP_IPV6ONLY
    // flag on the underlying uv_udp_t.
    bool ipv6_only = false;

    // When true, multiple endpoints (across separate processes) can bind to
    // the same address:port and the kernel will load-balance incoming UDP
    // datagrams across them. This sets the UV_UDP_REUSEPORT flag on the
    // underlying uv_udp_t. Supported on Linux 3.9+ and DragonFlyBSD 3.6+.
    bool reuse_port = false;

    uint32_t udp_receive_buffer_size = 0;
    uint32_t udp_send_buffer_size = 0;

    // The UDP TTL configuration is the number of network hops a packet will be
    // forwarded through. The default is 64. The value is in the range 1 to 255.
    // Setting to 0 uses the default.
    uint8_t udp_ttl = 0;

    // When an endpoint becomes idle (not listening and no primary sessions),
    // it will be destroyed after this many seconds. A value of 0 means
    // destroy immediately when idle (default, preserves pre-SessionManager
    // behavior). A positive value keeps the endpoint alive for potential
    // reuse by future connect() or listen() calls.
    static constexpr uint64_t DEFAULT_IDLE_TIMEOUT = 0;
    uint64_t idle_timeout = DEFAULT_IDLE_TIMEOUT;

    // Optional block list for filtering incoming packets by source address.
    // When block_list_policy is DENY, packets from addresses matching the
    // block list are dropped. When ALLOW, only packets from addresses
    // matching the block list are accepted (all others dropped).
    enum class BlockListPolicy : uint8_t {
      DENY,   // Drop packets from matching addresses (blocklist)
      ALLOW,  // Drop packets from non-matching addresses (allowlist)
    };
    std::shared_ptr<SocketAddressBlockList> block_list;
    BlockListPolicy block_list_policy = BlockListPolicy::DENY;

    void MemoryInfo(MemoryTracker* tracker) const override;
    SET_MEMORY_INFO_NAME(Endpoint::Config)
    SET_SELF_SIZE(Options)

    static v8::Maybe<Options> From(Environment* env,
                                   v8::Local<v8::Value> value);

    std::string ToString() const;
  };

  JS_CONSTRUCTOR(Endpoint);
  JS_BINDING_INIT_BOILERPLATE();

  Endpoint(Environment* env,
           v8::Local<v8::Object> object,
           const Endpoint::Options& options);

  inline operator Packet::Listener*() {
    return this;
  }

  inline const Options& options() const {
    return options_;
  }

  // While the busy flag is set, the Endpoint will reject all initial packets
  // with a SERVER_BUSY response. This allows us to build a circuit breaker
  // directly into the implementation, explicitly signaling that the server is
  // blocked when activity is too high.
  void MarkAsBusy(bool on = true);

  // Use the endpoint's token secret to generate a new token.
  RegularToken GenerateNewToken(uint32_t version,
                                const SocketAddress& remote_address);

  // Use the endpoint's reset token secret to generate a new stateless reset.
  StatelessResetToken GenerateNewStatelessResetToken(uint8_t* token,
                                                     const CID& cid) const;

  void AddSession(const CID& cid, BaseObjectPtr<Session> session);
  void RemoveSession(const CID& cid, const SocketAddress& remote_address);
  BaseObjectPtr<Session> FindSession(const CID& cid);

  // A single session may be associated with multiple CIDs.
  // AssociateCID registers the mapping both in the Endpoint and the inner
  // Endpoint.
  void AssociateCID(const CID& cid, const CID& scid);
  void DisassociateCID(const CID& cid);

  // Associates a given stateless reset token with the session. This allows
  // stateless reset tokens to be recognized and dispatched to the proper
  // Endpoint and Session for processing.
  void AssociateStatelessResetToken(const StatelessResetToken& token,
                                    Session* session);
  void DisassociateStatelessResetToken(const StatelessResetToken& token);

  void Send(Packet::Ptr packet);

  // Attempt synchronous send via uv_udp_try_send. If the socket is
  // writable, the packet is sent immediately and the Ptr is released.
  // If the socket is not writable (UV_EAGAIN), falls back to the
  // async Send path. Used by the deferred flush callback to avoid
  // the one-tick latency of async uv_udp_send.
  void SendOrTrySend(Packet::Ptr packet);

  // Send a batch of packets using uv_udp_try_send2 (sendmmsg) for
  // synchronous batched delivery. Packets successfully sent are released
  // immediately. On EAGAIN or partial send, remaining packets fall back
  // to async uv_udp_send. The Packet::Ptr array is consumed: all entries
  // will be empty (released or moved) on return.
  void SendBatch(Packet::Ptr* packets, size_t count);

  // Acquire a Packet from the pool. length sets the initial working
  // size (must be <= pool capacity). The slot is always allocated at
  // full capacity to avoid fragmentation.
  Packet::Ptr CreatePacket(const SocketAddress& destination,
                           size_t length = kDefaultMaxPacketLength,
                           const char* diagnostic_label = nullptr);

  // Generates and sends a retry packet. This is terminal for the connection.
  // Retry packets are used to force explicit path validation by issuing a token
  // to the peer that it must thereafter include in all subsequent initial
  // packets. Upon receiving a retry packet, the peer must termination it's
  // initial attempt to establish a connection and start a new attempt.
  //
  // Retry packets will only ever be generated by QUIC servers, and only if the
  // QuicSocket is configured for explicit path validation. There is no way for
  // a client to force a retry packet to be created. However, once a client
  // determines that explicit path validation is enabled, it could attempt to
  // DOS by sending a large number of malicious initial packets to intentionally
  // ellicit retry packets (It can do so by intentionally sending initial
  // packets that ignore the retry token). To help mitigate that risk, we limit
  // the number of retries we send to a given remote endpoint.
  void SendRetry(const PathDescriptor& options, uint64_t now);

  // Sends a version negotiation packet. This is terminal for the connection and
  // is sent only when a QUIC packet is received for an unsupported QUIC
  // version. It is possible that a malicious packet triggered this so we need
  // to be careful not to commit too many resources.
  void SendVersionNegotiation(const PathDescriptor& options, uint64_t now);

  // Possibly generates and sends a stateless reset packet. This is terminal for
  // the connection. It is possible that a malicious packet triggered this so we
  // need to be careful not to commit too many resources.
  bool SendStatelessReset(const PathDescriptor& options,
                          size_t source_len,
                          uint64_t now);

  // Shutdown a connection prematurely, before a Session is created. This should
  // only be called at the start of a session before the crypto keys have been
  // established.
  void SendImmediateConnectionClose(const PathDescriptor& options,
                                    QuicError error,
                                    uint64_t now);

  // Listen for connections (act as a server).
  void Listen(const Session::Options& options);

  // Create a new client-side Session.
  BaseObjectPtr<Session> Connect(
      const SocketAddress& remote_address,
      const Session::Options& options,
      std::optional<SessionTicket> sessionTicket = std::nullopt);

  // Returns the local address only if the endpoint has been bound. Before
  // the endpoint is bound, or after it is closed, this will abort due to
  // a failed check so it is important to check `is_closed()` before calling.
  SocketAddress local_address() const;

  void MemoryInfo(MemoryTracker* tracker) const override;
  SET_MEMORY_INFO_NAME(Endpoint)
  SET_SELF_SIZE(Endpoint)

  struct Stats;
  struct State;

 private:
  class UDP final : public MemoryRetainer {
   public:
    explicit UDP(Endpoint* endpoint);
    ~UDP() override;

    int Bind(const Endpoint::Options& config);
    int Start();
    void Stop();
    void Close();
    int Send(Packet::Ptr packet);

    // Synchronous send using uv_udp_try_send. Returns the number of
    // bytes sent on success, UV_EAGAIN if the socket is not writable
    // or the send queue is non-empty, or another negative error code.
    // The Ptr is not consumed — the caller manages the lifecycle.
    int TrySend(const Packet::Ptr& packet);

    // Synchronous batched send using uv_udp_try_send2 (sendmmsg).
    // Takes pre-built libuv argument arrays. Returns the number of
    // messages successfully sent (>= 0), or a negative error code.
    int TrySendBatch(uv_buf_t* bufs[],
                     unsigned int nbufs[],
                     struct sockaddr* addrs[],
                     size_t count);

    // Returns the local UDP socket address to which we are bound,
    // or fail with an assert if we are not bound.
    SocketAddress local_address() const;

    bool is_bound() const;
    bool is_closed() const;
    bool is_closed_or_closing() const;
    operator bool() const;

    void Ref();
    void Unref();

    void MemoryInfo(node::MemoryTracker* tracker) const override;
    SET_MEMORY_INFO_NAME(Endpoint::UDP)
    SET_SELF_SIZE(UDP)

   private:
    class Impl;

    BaseObjectWeakPtr<Impl> impl_;
    struct Flags {
      uint8_t is_bound : 1 = 0;
      uint8_t is_started : 1 = 0;
      uint8_t is_closed : 1 = 0;
    };
    Flags flags_;
  };

  bool is_closed() const;
  bool is_closing() const;
  bool is_listening() const;

  bool Start();

  // Destroy the endpoint if...
  // * There are no sessions,
  // * There are no sent packets with pending done callbacks, and
  // * We're not listening for new initial packets.
  void MaybeDestroy();

  // Specifies the general reason the endpoint is being destroyed.
  enum class CloseContext : uint8_t {
    CLOSE,
    BIND_FAILURE,
    START_FAILURE,
    RECEIVE_FAILURE,
    SEND_FAILURE,
    LISTEN_FAILURE,
  };

  void Destroy(CloseContext context = CloseContext::CLOSE, int status = 0);

  // A graceful close will destroy the endpoint once all existing sessions
  // have ended normally. Creating new sessions (inbound or outbound) will
  // be prevented.
  void CloseGracefully();

  void PacketDone(int status) override;

  void EmitNewSession(const BaseObjectPtr<Session>& session);
  void EmitClose(CloseContext context, int status);

  // JavaScript API

  // Create a new Endpoint.
  // @param Endpoint::Options options - Options to configure the Endpoint.
  JS_METHOD(New);

  // Methods on the Endpoint instance:

  // Create a new client Session on this endpoint.
  // @param node::SocketAddress local_address - The local address to bind to.
  // @param Session::Options options - Options to configure the Session.
  // @param v8::ArrayBufferView session_ticket - The session ticket to use for
  // the Session.
  // @param v8::ArrayBufferView remote_transport_params - The remote transport
  // params.
  JS_METHOD(DoConnect);

  // Start listening as a QUIC server
  // @param Session::Options options - Options to configure the Session.
  JS_METHOD(DoListen);

  // Mark the Endpoint as busy, temporarily pausing handling of new initial
  // packets.
  // @param bool on - If true, mark the Endpoint as busy.
  JS_METHOD(MarkBusy);

  // DoCloseGracefully is the signal that endpoint should close. Any packets
  // that are already in the queue or in flight will be allowed to finish, but
  // the EndpoingWrap will be otherwise no longer able to receive or send
  // packets.
  JS_METHOD(DoCloseGracefully);

  JS_METHOD(DoSetSNIContexts);

  // Get the local address of the Endpoint.
  // @return node::SocketAddress - The local address of the Endpoint.
  JS_METHOD(LocalAddress);

  // Ref() causes a listening Endpoint to keep the event loop active.
  JS_METHOD(Ref);

  void Receive(const uint8_t* data, size_t len, const SocketAddress& from);

  AliasedStruct<Stats> stats_;
  AliasedStruct<State> state_;
  const Options options_;
  ArenaPool<Packet> packet_pool_;
  UDP udp_;

  // Idle timer: started when the endpoint becomes idle (not listening,
  // no primary sessions). When it fires, the endpoint is destroyed.
  // Stopped when a new session is added or listening begins.
  TimerWrapHandle idle_timer_;

  struct ServerState {
    Session::Options options;
    std::shared_ptr<TLSContext> tls_context;
  };
  // Set if/when the endpoint is configured to listen.
  std::optional<ServerState> server_state_ = std::nullopt;

  // Count of sessions for which this endpoint is the primary endpoint.
  // Drives ref/unref and idle timer logic. The actual session-to-endpoint
  // mapping is maintained by the SessionManager.
  size_t primary_session_count_ = 0;

  // Per-endpoint CID -> SCID mapping for peer-chosen CIDs from connection
  // establishment (config.dcid, config.ocid). These are kept per-endpoint
  // because peer-chosen values can collide across endpoints (e.g., a
  // client's random outgoing DCID matching an incoming DCID on the server
  // endpoint). Locally-generated CIDs that need cross-endpoint routing
  // (preferred address, multipath) go in SessionManager::dcid_to_scid_.
  //
  // Endpoint::FindSession does a three-tier lookup:
  //   1. SessionManager::sessions_[cid]          (direct SCID match)
  //   2. SessionManager::dcid_to_scid_[cid]      (cross-endpoint CID)
  //   3. Endpoint::dcid_to_scid_[cid]            (peer-chosen CID)
  // Each tier resolves to an SCID and looks up SessionManager::sessions_.
  CID::Map<CID> dcid_to_scid_;

  SessionManager& session_manager() const;

  struct SocketAddressInfoTraits final {
    struct Type final {
      uint64_t timestamp;
      bool validated;
      TokenBucket session_creation_bucket;
    };

    static bool CheckExpired(const SocketAddress& address,
                             const Type& type,
                             uint64_t now);
    static void Touch(const SocketAddress& address, Type* type, uint64_t now);
  };

  SocketAddressLRU<SocketAddressInfoTraits> addr_validation_lru_;

  // Global token buckets for stateless response rate limiting.
  // These cap the total server-wide rate of each response type,
  // regardless of source address.
  TokenBucket retry_bucket_;
  TokenBucket stateless_reset_bucket_;
  TokenBucket version_negotiation_bucket_;
  TokenBucket immediate_close_bucket_;

  // Per-IP connection counts for maxConnectionsPerHost enforcement.
  // Only populated when max_connections_per_host > 0. Entries are
  // added in AddSession and removed when the count reaches 0 in
  // RemoveSession. The map size is bounded by the number of active
  // sessions (each entry has count >= 1).
  SocketAddress::IpMap<uint16_t> conn_counts_per_host_;

  CloseContext close_context_ = CloseContext::CLOSE;
  int close_status_ = 0;

  friend class UDP;
  friend class Packet;
  friend class Session;
};

}  // namespace node::quic

#endif  // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS