CONNECT-IP encapsulated client support

This CL adds a new mode to masque_client that allows testing HTTP/3 over CONNECT-IP (as opposed to HTTP/3 over CONNECT-UDP). It creates UDP and IP headers on the fly.

This code isn't used in production, it solely exists for IETF interop and therefore does not have adequate test coverage or flag protection. Those will be added in the future when we migrate the code out of the masque directory.

PiperOrigin-RevId: 484744539
diff --git a/quiche/quic/masque/masque_client_bin.cc b/quiche/quic/masque/masque_client_bin.cc
index ae495f8..58c388b 100644
--- a/quiche/quic/masque/masque_client_bin.cc
+++ b/quiche/quic/masque/masque_client_bin.cc
@@ -89,9 +89,15 @@
   }
   MasqueMode masque_mode = MasqueMode::kOpen;
   std::string mode_string = quiche::GetQuicheCommandLineFlag(FLAGS_masque_mode);
-  if (!mode_string.empty() && mode_string != "open") {
-    std::cerr << "Invalid masque_mode \"" << mode_string << "\"" << std::endl;
-    return 1;
+  if (!mode_string.empty()) {
+    if (mode_string == "open") {
+      masque_mode = MasqueMode::kOpen;
+    } else if (mode_string == "connectip" || mode_string == "connect-ip") {
+      masque_mode = MasqueMode::kConnectIp;
+    } else {
+      std::cerr << "Invalid masque_mode \"" << mode_string << "\"" << std::endl;
+      return 1;
+    }
   }
   std::unique_ptr<MasqueClient> masque_client = MasqueClient::Create(
       uri_template, masque_mode, event_loop.get(), std::move(proof_verifier));
diff --git a/quiche/quic/masque/masque_client_session.cc b/quiche/quic/masque/masque_client_session.cc
index 03d2cfb..8e5bbfa 100644
--- a/quiche/quic/masque/masque_client_session.cc
+++ b/quiche/quic/masque/masque_client_session.cc
@@ -15,7 +15,9 @@
 #include "url/url_canon.h"
 #include "quiche/quic/core/http/spdy_utils.h"
 #include "quiche/quic/core/quic_data_reader.h"
+#include "quiche/quic/core/quic_data_writer.h"
 #include "quiche/quic/core/quic_utils.h"
+#include "quiche/quic/masque/masque_utils.h"
 #include "quiche/quic/platform/api/quic_socket_address.h"
 #include "quiche/quic/tools/quic_url.h"
 #include "quiche/common/platform/api/quiche_url_utils.h"
@@ -23,6 +25,10 @@
 
 namespace quic {
 
+namespace {
+constexpr uint64_t kConnectIpPayloadContextId = 0;
+}
+
 MasqueClientSession::MasqueClientSession(
     MasqueMode masque_mode, const std::string& uri_template,
     const QuicConfig& config, const ParsedQuicVersionVector& supported_versions,
@@ -33,7 +39,9 @@
                             crypto_config, push_promise_index),
       masque_mode_(masque_mode),
       uri_template_(uri_template),
-      owner_(owner) {}
+      owner_(owner) {
+  connection->SetMaxPacketLength(1400);
+}
 
 void MasqueClientSession::OnMessageAcked(QuicMessageId message_id,
                                          QuicTime /*receive_timestamp*/) {
@@ -138,6 +146,83 @@
   return &connect_udp_client_states_.back();
 }
 
+const MasqueClientSession::ConnectIpClientState*
+MasqueClientSession::GetOrCreateConnectIpClientState(
+    MasqueClientSession::EncapsulatedIpSession* encapsulated_ip_session) {
+  for (const ConnectIpClientState& client_state : connect_ip_client_states_) {
+    if (client_state.encapsulated_ip_session() == encapsulated_ip_session) {
+      // Found existing CONNECT-IP request.
+      return &client_state;
+    }
+  }
+  // No CONNECT-IP request found, create a new one.
+  QuicSpdyClientStream* stream = CreateOutgoingBidirectionalStream();
+  if (stream == nullptr) {
+    // Stream flow control limits prevented us from opening a new stream.
+    QUIC_DLOG(ERROR) << "Failed to open CONNECT-IP stream";
+    return nullptr;
+  }
+
+  QuicUrl url(uri_template_);
+  std::string scheme = url.scheme();
+  std::string authority = url.HostPort();
+  std::string path = "/.well-known/masque/ip/*/*/";
+
+  QUIC_DLOG(INFO) << "Sending CONNECT-IP request on stream " << stream->id()
+                  << " scheme=\"" << scheme << "\" authority=\"" << authority
+                  << "\" path=\"" << path << "\"";
+
+  // Send the request.
+  spdy::Http2HeaderBlock headers;
+  headers[":method"] = "CONNECT";
+  headers[":protocol"] = "connect-ip";
+  headers[":scheme"] = scheme;
+  headers[":authority"] = authority;
+  headers[":path"] = path;
+  headers["connect-ip-version"] = "3";
+  size_t bytes_sent =
+      stream->SendRequest(std::move(headers), /*body=*/"", /*fin=*/false);
+  if (bytes_sent == 0) {
+    QUIC_DLOG(ERROR) << "Failed to send CONNECT-IP request";
+    return nullptr;
+  }
+
+  connect_ip_client_states_.push_back(
+      ConnectIpClientState(stream, encapsulated_ip_session, this));
+  return &connect_ip_client_states_.back();
+}
+
+void MasqueClientSession::SendIpPacket(
+    absl::string_view packet,
+    MasqueClientSession::EncapsulatedIpSession* encapsulated_ip_session) {
+  const ConnectIpClientState* connect_ip =
+      GetOrCreateConnectIpClientState(encapsulated_ip_session);
+  if (connect_ip == nullptr) {
+    QUIC_DLOG(ERROR) << "Failed to create CONNECT-IP request";
+    return;
+  }
+
+  std::string http_payload;
+  http_payload.resize(
+      QuicDataWriter::GetVarInt62Len(kConnectIpPayloadContextId) +
+      packet.size());
+  QuicDataWriter writer(http_payload.size(), http_payload.data());
+  if (!writer.WriteVarInt62(kConnectIpPayloadContextId)) {
+    QUIC_BUG(IP context write fail) << "Failed to write CONNECT-IP context ID";
+    return;
+  }
+  if (!writer.WriteStringPiece(packet)) {
+    QUIC_BUG(IP packet write fail) << "Failed to write CONNECT-IP packet";
+    return;
+  }
+  MessageStatus message_status =
+      SendHttp3Datagram(connect_ip->stream()->id(), http_payload);
+
+  QUIC_DVLOG(1) << "Sent IP packet with stream ID "
+                << connect_ip->stream()->id() << " and got message status "
+                << MessageStatusToString(message_status);
+}
+
 void MasqueClientSession::SendPacket(
     absl::string_view packet, const QuicSocketAddress& target_server_address,
     EncapsulatedClientSession* encapsulated_client_session) {
@@ -166,7 +251,8 @@
   for (auto it = connect_udp_client_states_.begin();
        it != connect_udp_client_states_.end();) {
     if (it->encapsulated_client_session() == encapsulated_client_session) {
-      QUIC_DLOG(INFO) << "Removing state for stream ID " << it->stream()->id();
+      QUIC_DLOG(INFO) << "Removing CONNECT-UDP state for stream ID "
+                      << it->stream()->id();
       auto* stream = it->stream();
       it = connect_udp_client_states_.erase(it);
       if (!stream->write_side_closed()) {
@@ -178,6 +264,24 @@
   }
 }
 
+void MasqueClientSession::CloseConnectIpStream(
+    EncapsulatedIpSession* encapsulated_ip_session) {
+  for (auto it = connect_ip_client_states_.begin();
+       it != connect_ip_client_states_.end();) {
+    if (it->encapsulated_ip_session() == encapsulated_ip_session) {
+      QUIC_DLOG(INFO) << "Removing CONNECT-IP state for stream ID "
+                      << it->stream()->id();
+      auto* stream = it->stream();
+      it = connect_ip_client_states_.erase(it);
+      if (!stream->write_side_closed()) {
+        stream->Reset(QUIC_STREAM_CANCELLED);
+      }
+    } else {
+      ++it;
+    }
+  }
+}
+
 void MasqueClientSession::OnConnectionClosed(
     const QuicConnectionCloseFrame& frame, ConnectionCloseSource source) {
   QuicSpdyClientSession::OnConnectionClosed(frame, source);
@@ -187,6 +291,10 @@
         QUIC_CONNECTION_CANCELLED, "Underlying MASQUE connection was closed",
         ConnectionCloseBehavior::SILENT_CLOSE);
   }
+  for (const auto& client_state : connect_ip_client_states_) {
+    client_state.encapsulated_ip_session()->CloseIpSession(
+        "Underlying MASQUE connection was closed");
+  }
 }
 
 void MasqueClientSession::OnStreamClosed(QuicStreamId stream_id) {
@@ -204,7 +312,7 @@
        it != connect_udp_client_states_.end();) {
     if (it->stream()->id() == stream_id) {
       QUIC_DLOG(INFO) << "Stream " << stream_id
-                      << " was closed, removing state";
+                      << " was closed, removing CONNECT-UDP state";
       auto* encapsulated_client_session = it->encapsulated_client_session();
       it = connect_udp_client_states_.erase(it);
       encapsulated_client_session->CloseConnection(
@@ -215,6 +323,19 @@
       ++it;
     }
   }
+  for (auto it = connect_ip_client_states_.begin();
+       it != connect_ip_client_states_.end();) {
+    if (it->stream()->id() == stream_id) {
+      QUIC_DLOG(INFO) << "Stream " << stream_id
+                      << " was closed, removing CONNECT-IP state";
+      auto* encapsulated_ip_session = it->encapsulated_ip_session();
+      it = connect_ip_client_states_.erase(it);
+      encapsulated_ip_session->CloseIpSession(
+          "Underlying MASQUE CONNECT-IP stream was closed");
+    } else {
+      ++it;
+    }
+  }
 
   QuicSpdyClientSession::OnStreamClosed(stream_id);
 }
@@ -293,4 +414,79 @@
                 << " bytes to connection for stream ID " << stream_id;
 }
 
+MasqueClientSession::ConnectIpClientState::ConnectIpClientState(
+    QuicSpdyClientStream* stream,
+    EncapsulatedIpSession* encapsulated_ip_session,
+    MasqueClientSession* masque_session)
+    : stream_(stream),
+      encapsulated_ip_session_(encapsulated_ip_session),
+      masque_session_(masque_session) {
+  QUICHE_DCHECK_NE(masque_session_, nullptr);
+  this->stream()->RegisterHttp3DatagramVisitor(this);
+  this->stream()->RegisterConnectIpVisitor(this);
+}
+
+MasqueClientSession::ConnectIpClientState::~ConnectIpClientState() {
+  if (stream() != nullptr) {
+    stream()->UnregisterHttp3DatagramVisitor();
+    stream()->UnregisterConnectIpVisitor();
+  }
+}
+
+MasqueClientSession::ConnectIpClientState::ConnectIpClientState(
+    MasqueClientSession::ConnectIpClientState&& other) {
+  *this = std::move(other);
+}
+
+MasqueClientSession::ConnectIpClientState&
+MasqueClientSession::ConnectIpClientState::operator=(
+    MasqueClientSession::ConnectIpClientState&& other) {
+  stream_ = other.stream_;
+  encapsulated_ip_session_ = other.encapsulated_ip_session_;
+  masque_session_ = other.masque_session_;
+  other.stream_ = nullptr;
+  if (stream() != nullptr) {
+    stream()->ReplaceHttp3DatagramVisitor(this);
+    stream()->ReplaceConnectIpVisitor(this);
+  }
+  return *this;
+}
+
+void MasqueClientSession::ConnectIpClientState::OnHttp3Datagram(
+    QuicStreamId stream_id, absl::string_view payload) {
+  QUICHE_DCHECK_EQ(stream_id, stream()->id());
+  QuicDataReader reader(payload);
+  uint64_t context_id;
+  if (!reader.ReadVarInt62(&context_id)) {
+    QUIC_DLOG(ERROR) << "Failed to read context ID";
+    return;
+  }
+  if (context_id != kConnectIpPayloadContextId) {
+    QUIC_DLOG(ERROR) << "Ignoring HTTP Datagram with unexpected context ID "
+                     << context_id;
+    return;
+  }
+  absl::string_view http_payload = reader.ReadRemainingPayload();
+  encapsulated_ip_session_->ProcessIpPacket(http_payload);
+  QUIC_DVLOG(1) << "Sent " << http_payload.size()
+                << " IP bytes to connection for stream ID " << stream_id;
+}
+
+bool MasqueClientSession::ConnectIpClientState::OnAddressAssignCapsule(
+    const AddressAssignCapsule& capsule) {
+  return encapsulated_ip_session_->OnAddressAssignCapsule(capsule);
+}
+
+bool MasqueClientSession::ConnectIpClientState::OnAddressRequestCapsule(
+    const AddressRequestCapsule& capsule) {
+  return encapsulated_ip_session_->OnAddressRequestCapsule(capsule);
+}
+
+bool MasqueClientSession::ConnectIpClientState::OnRouteAdvertisementCapsule(
+    const RouteAdvertisementCapsule& capsule) {
+  return encapsulated_ip_session_->OnRouteAdvertisementCapsule(capsule);
+}
+
+void MasqueClientSession::ConnectIpClientState::OnHeadersWritten() {}
+
 }  // namespace quic
diff --git a/quiche/quic/masque/masque_client_session.h b/quiche/quic/masque/masque_client_session.h
index 43609dc..2b48e49 100644
--- a/quiche/quic/masque/masque_client_session.h
+++ b/quiche/quic/masque/masque_client_session.h
@@ -32,13 +32,16 @@
     // Notifies the owner that a settings frame has been received.
     virtual void OnSettingsReceived() = 0;
   };
-  // Interface meant to be implemented by encapsulated client sessions, i.e.
-  // the end-to-end QUIC client sessions that run inside MASQUE encapsulation.
+
+  // Interface meant to be implemented by client sessions encapsulated inside
+  // CONNECT-UDP, i.e. the end-to-end QUIC client sessions that run inside
+  // CONNECT-UDP encapsulation.
   class QUIC_NO_EXPORT EncapsulatedClientSession {
    public:
     virtual ~EncapsulatedClientSession() {}
 
-    // Process packet that was just decapsulated.
+    // Process UDP packet that was just decapsulated. |packet| contains the UDP
+    // payload.
     virtual void ProcessPacket(absl::string_view packet,
                                QuicSocketAddress target_server_address) = 0;
 
@@ -48,6 +51,28 @@
         ConnectionCloseBehavior connection_close_behavior) = 0;
   };
 
+  // Interface meant to be implemented by client sessions encapsulated inside
+  // CONNECT-IP, i.e. the end-to-end QUIC client sessions that run inside
+  // CONNECT-IP encapsulation.
+  class QUIC_NO_EXPORT EncapsulatedIpSession {
+   public:
+    virtual ~EncapsulatedIpSession() {}
+
+    // Process packet that was just decapsulated. |packet| contains the IP
+    // header and payload.
+    virtual void ProcessIpPacket(absl::string_view packet) = 0;
+
+    // Close the encapsulated connection.
+    virtual void CloseIpSession(const std::string& details) = 0;
+
+    virtual bool OnAddressAssignCapsule(
+        const AddressAssignCapsule& capsule) = 0;
+    virtual bool OnAddressRequestCapsule(
+        const AddressRequestCapsule& capsule) = 0;
+    virtual bool OnRouteAdvertisementCapsule(
+        const RouteAdvertisementCapsule& capsule) = 0;
+  };
+
   // Takes ownership of |connection|, but not of |crypto_config| or
   // |push_promise_index| or |owner|. All pointers must be non-null. Caller
   // must ensure that |push_promise_index| and |owner| stay valid for the
@@ -75,15 +100,22 @@
   // From QuicSpdySession.
   bool OnSettingsFrame(const SettingsFrame& frame) override;
 
-  // Send encapsulated packet.
+  // Send encapsulated UDP packet. |packet| contains the UDP payload.
   void SendPacket(absl::string_view packet,
                   const QuicSocketAddress& target_server_address,
                   EncapsulatedClientSession* encapsulated_client_session);
 
+  // Send encapsulated IP packet. |packet| contains the IP header and payload.
+  void SendIpPacket(absl::string_view packet,
+                    EncapsulatedIpSession* encapsulated_ip_session);
+
   // Close CONNECT-UDP stream tied to this encapsulated client session.
   void CloseConnectUdpStream(
       EncapsulatedClientSession* encapsulated_client_session);
 
+  // Close CONNECT-IP stream tied to this encapsulated client session.
+  void CloseConnectIpStream(EncapsulatedIpSession* encapsulated_ip_session);
+
  private:
   // State that the MasqueClientSession keeps for each CONNECT-UDP request.
   class QUIC_NO_EXPORT ConnectUdpClientState
@@ -124,6 +156,48 @@
     QuicSocketAddress target_server_address_;
   };
 
+  // State that the MasqueClientSession keeps for each CONNECT-IP request.
+  class QUIC_NO_EXPORT ConnectIpClientState
+      : public QuicSpdyStream::Http3DatagramVisitor,
+        public QuicSpdyStream::ConnectIpVisitor {
+   public:
+    // |stream| and |encapsulated_client_session| must be valid for the lifetime
+    // of the ConnectUdpClientState.
+    explicit ConnectIpClientState(
+        QuicSpdyClientStream* stream,
+        EncapsulatedIpSession* encapsulated_ip_session,
+        MasqueClientSession* masque_session);
+
+    ~ConnectIpClientState();
+
+    // Disallow copy but allow move.
+    ConnectIpClientState(const ConnectIpClientState&) = delete;
+    ConnectIpClientState(ConnectIpClientState&&);
+    ConnectIpClientState& operator=(const ConnectIpClientState&) = delete;
+    ConnectIpClientState& operator=(ConnectIpClientState&&);
+
+    QuicSpdyClientStream* stream() const { return stream_; }
+    EncapsulatedIpSession* encapsulated_ip_session() const {
+      return encapsulated_ip_session_;
+    }
+
+    // From QuicSpdyStream::Http3DatagramVisitor.
+    void OnHttp3Datagram(QuicStreamId stream_id,
+                         absl::string_view payload) override;
+
+    // From QuicSpdyStream::ConnectIpVisitor.
+    bool OnAddressAssignCapsule(const AddressAssignCapsule& capsule) override;
+    bool OnAddressRequestCapsule(const AddressRequestCapsule& capsule) override;
+    bool OnRouteAdvertisementCapsule(
+        const RouteAdvertisementCapsule& capsule) override;
+    void OnHeadersWritten() override;
+
+   private:
+    QuicSpdyClientStream* stream_;                    // Unowned.
+    EncapsulatedIpSession* encapsulated_ip_session_;  // Unowned.
+    MasqueClientSession* masque_session_;             // Unowned.
+  };
+
   HttpDatagramSupport LocalHttpDatagramSupport() override {
     return HttpDatagramSupport::kRfc;
   }
@@ -132,9 +206,13 @@
       const QuicSocketAddress& target_server_address,
       EncapsulatedClientSession* encapsulated_client_session);
 
+  const ConnectIpClientState* GetOrCreateConnectIpClientState(
+      EncapsulatedIpSession* encapsulated_ip_session);
+
   MasqueMode masque_mode_;
   std::string uri_template_;
   std::list<ConnectUdpClientState> connect_udp_client_states_;
+  std::list<ConnectIpClientState> connect_ip_client_states_;
   Owner* owner_;  // Unowned;
 };
 
diff --git a/quiche/quic/masque/masque_encapsulated_client.cc b/quiche/quic/masque/masque_encapsulated_client.cc
index a8d6207..2d26791 100644
--- a/quiche/quic/masque/masque_encapsulated_client.cc
+++ b/quiche/quic/masque/masque_encapsulated_client.cc
@@ -4,17 +4,69 @@
 
 #include "quiche/quic/masque/masque_encapsulated_client.h"
 
+#include <optional>
+
 #include "quiche/quic/core/quic_utils.h"
 #include "quiche/quic/masque/masque_client.h"
 #include "quiche/quic/masque/masque_client_session.h"
 #include "quiche/quic/masque/masque_encapsulated_client_session.h"
 #include "quiche/quic/masque/masque_utils.h"
 #include "quiche/quic/tools/quic_client_default_network_helper.h"
+#include "quiche/common/quiche_data_reader.h"
+#include "quiche/common/quiche_data_writer.h"
 
 namespace quic {
 
 namespace {
 
+class ChecksumWriter {
+ public:
+  explicit ChecksumWriter(quiche::QuicheDataWriter& writer) : writer_(writer) {}
+  void IngestUInt16(uint16_t val) { accumulator_ += val; }
+  void IngestUInt8(uint8_t val) {
+    uint16_t val16 = odd_ ? val : (val << 8);
+    accumulator_ += val16;
+    odd_ = !odd_;
+  }
+  bool IngestData(size_t offset, size_t length) {
+    quiche::QuicheDataReader reader(writer_.data(), writer_.capacity());
+    if (!reader.Seek(offset) || reader.BytesRemaining() < length) {
+      return false;
+    }
+    // Handle any potentially off first byte.
+    uint8_t first_byte;
+    if (odd_ && reader.ReadUInt8(&first_byte)) {
+      IngestUInt8(first_byte);
+    }
+    // Handle each 16-bit word at a time.
+    while (reader.BytesRemaining() > sizeof(uint16_t)) {
+      uint16_t word;
+      if (!reader.ReadUInt16(&word)) {
+        return false;
+      }
+      IngestUInt16(word);
+    }
+    // Handle any leftover odd byte.
+    uint8_t last_byte;
+    if (reader.ReadUInt8(&last_byte)) {
+      IngestUInt8(last_byte);
+    }
+    return true;
+  }
+  bool WriteChecksumAtOffset(size_t offset) {
+    while (accumulator_ >> 16 > 0) {
+      accumulator_ = (accumulator_ & 0xffff) + (accumulator_ >> 16);
+    }
+    quiche::QuicheDataWriter writer2(writer_.capacity(), writer_.data());
+    return writer2.Seek(offset) && writer2.WriteUInt16(accumulator_);
+  }
+
+ private:
+  quiche::QuicheDataWriter& writer_;
+  uint32_t accumulator_ = 0xffff;
+  bool odd_ = false;
+};
+
 // Custom packet writer that allows getting all of a connection's outgoing
 // packets.
 class MasquePacketWriter : public QuicPacketWriter {
@@ -28,9 +80,103 @@
     QUICHE_DCHECK(peer_address.IsInitialized());
     QUIC_DVLOG(1) << "MasquePacketWriter trying to write " << buf_len
                   << " bytes to " << peer_address;
-    absl::string_view packet(buffer, buf_len);
-    client_->masque_client()->masque_client_session()->SendPacket(
-        packet, peer_address, client_->masque_encapsulated_client_session());
+    if (client_->masque_client()->masque_mode() == MasqueMode::kConnectIp) {
+      constexpr size_t kIPv4HeaderSize = 20;
+      constexpr size_t kIPv4ChecksumOffset = 10;
+      constexpr size_t kIPv6HeaderSize = 40;
+      constexpr size_t kUdpHeaderSize = 8;
+      const size_t udp_length = kUdpHeaderSize + buf_len;
+      std::string packet;
+      packet.resize(
+          (peer_address.host().IsIPv6() ? kIPv6HeaderSize : kIPv4HeaderSize) +
+          udp_length);
+      quiche::QuicheDataWriter writer(packet.size(), packet.data());
+      if (peer_address.host().IsIPv6()) {
+        // Write IPv6 header.
+        QUICHE_CHECK(writer.WriteUInt8(0x60));  // Version = 6 and DSCP.
+        QUICHE_CHECK(writer.WriteUInt8(0));     // DSCP/ECN and flow label.
+        QUICHE_CHECK(writer.WriteUInt16(0));    // Flow label.
+        QUICHE_CHECK(writer.WriteUInt16(udp_length));  // Payload Length.
+        QUICHE_CHECK(writer.WriteUInt8(17));           // Next header = UDP.
+        QUICHE_CHECK(writer.WriteUInt8(64));           // Hop limit = 64.
+        in6_addr source_address = {};
+        if (client_->masque_encapsulated_client_session()
+                ->local_v6_address()
+                .IsIPv6()) {
+          source_address = client_->masque_encapsulated_client_session()
+                               ->local_v6_address()
+                               .GetIPv6();
+        }
+        QUICHE_CHECK(
+            writer.WriteBytes(&source_address, sizeof(source_address)));
+        in6_addr destination_address = peer_address.host().GetIPv6();
+        QUICHE_CHECK(writer.WriteBytes(&destination_address,
+                                       sizeof(destination_address)));
+      } else {
+        // Write IPv4 header.
+        QUICHE_CHECK(writer.WriteUInt8(0x45));  // Version = 4, IHL = 5.
+        QUICHE_CHECK(writer.WriteUInt8(0));     // DSCP/ECN.
+        QUICHE_CHECK(writer.WriteUInt16(packet.size()));  // Total Length.
+        QUICHE_CHECK(writer.WriteUInt32(0));              // No fragmentation.
+        QUICHE_CHECK(writer.WriteUInt8(64));              // TTL = 64.
+        QUICHE_CHECK(writer.WriteUInt8(17));              // IP Protocol = UDP.
+        QUICHE_CHECK(writer.WriteUInt16(0));  // Checksum = 0 initially.
+        in_addr source_address = {};
+        if (client_->masque_encapsulated_client_session()
+                ->local_v4_address()
+                .IsIPv4()) {
+          source_address = client_->masque_encapsulated_client_session()
+                               ->local_v4_address()
+                               .GetIPv4();
+        }
+        QUICHE_CHECK(
+            writer.WriteBytes(&source_address, sizeof(source_address)));
+        in_addr destination_address = peer_address.host().GetIPv4();
+        QUICHE_CHECK(writer.WriteBytes(&destination_address,
+                                       sizeof(destination_address)));
+        ChecksumWriter ip_checksum_writer(writer);
+        QUICHE_CHECK(ip_checksum_writer.IngestData(0, kIPv4HeaderSize));
+        QUICHE_CHECK(
+            ip_checksum_writer.WriteChecksumAtOffset(kIPv4ChecksumOffset));
+      }
+      // Write UDP header.
+      QUICHE_CHECK(writer.WriteUInt16(0x1234));  // Source port.
+      QUICHE_CHECK(
+          writer.WriteUInt16(peer_address.port()));  // Destination port.
+      QUICHE_CHECK(writer.WriteUInt16(udp_length));  // UDP length.
+      QUICHE_CHECK(writer.WriteUInt16(0));           // Checksum = 0 initially.
+      // Write UDP payload.
+      QUICHE_CHECK(writer.WriteBytes(buffer, buf_len));
+      ChecksumWriter udp_checksum_writer(writer);
+      if (peer_address.host().IsIPv6()) {
+        QUICHE_CHECK(udp_checksum_writer.IngestData(8, 32));  // IP addresses.
+        udp_checksum_writer.IngestUInt16(0);  // High bits of UDP length.
+        udp_checksum_writer.IngestUInt16(
+            udp_length);                      // Low bits of UDP length.
+        udp_checksum_writer.IngestUInt16(0);  // Zeroes.
+        udp_checksum_writer.IngestUInt8(0);   // Zeroes.
+        udp_checksum_writer.IngestUInt8(17);  // Next header = UDP.
+        QUICHE_CHECK(udp_checksum_writer.IngestData(
+            kIPv6HeaderSize, udp_length));  // UDP header and data.
+        QUICHE_CHECK(
+            udp_checksum_writer.WriteChecksumAtOffset(kIPv6HeaderSize + 6));
+      } else {
+        QUICHE_CHECK(udp_checksum_writer.IngestData(12, 8));  // IP addresses.
+        udp_checksum_writer.IngestUInt8(0);                   // Zeroes.
+        udp_checksum_writer.IngestUInt8(17);           // IP Protocol = UDP.
+        udp_checksum_writer.IngestUInt16(udp_length);  // UDP length.
+        QUICHE_CHECK(udp_checksum_writer.IngestData(
+            kIPv4HeaderSize, udp_length));  // UDP header and data.
+        QUICHE_CHECK(
+            udp_checksum_writer.WriteChecksumAtOffset(kIPv4HeaderSize + 6));
+      }
+      client_->masque_client()->masque_client_session()->SendIpPacket(
+          packet, client_->masque_encapsulated_client_session());
+    } else {
+      absl::string_view packet(buffer, buf_len);
+      client_->masque_client()->masque_client_session()->SendPacket(
+          packet, peer_address, client_->masque_encapsulated_client_session());
+    }
     return WriteResult(WRITE_STATUS_OK, buf_len);
   }
 
diff --git a/quiche/quic/masque/masque_encapsulated_client_session.cc b/quiche/quic/masque/masque_encapsulated_client_session.cc
index 077430f..ababe59 100644
--- a/quiche/quic/masque/masque_encapsulated_client_session.cc
+++ b/quiche/quic/masque/masque_encapsulated_client_session.cc
@@ -4,6 +4,10 @@
 
 #include "quiche/quic/masque/masque_encapsulated_client_session.h"
 
+#include "absl/strings/string_view.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/quiche_ip_address.h"
+
 namespace quic {
 
 MasqueEncapsulatedClientSession::MasqueEncapsulatedClientSession(
@@ -14,7 +18,9 @@
     MasqueClientSession* masque_client_session)
     : QuicSpdyClientSession(config, supported_versions, connection, server_id,
                             crypto_config, push_promise_index),
-      masque_client_session_(masque_client_session) {}
+      masque_client_session_(masque_client_session) {
+  connection->SetMaxPacketLength(1250);
+}
 
 void MasqueEncapsulatedClientSession::ProcessPacket(
     absl::string_view packet, QuicSocketAddress server_address) {
@@ -36,4 +42,166 @@
   masque_client_session_->CloseConnectUdpStream(this);
 }
 
+void MasqueEncapsulatedClientSession::ProcessIpPacket(
+    absl::string_view packet) {
+  quiche::QuicheDataReader reader(packet);
+  uint8_t first_byte;
+  if (!reader.ReadUInt8(&first_byte)) {
+    QUIC_DLOG(ERROR) << "Dropping empty CONNECT-IP packet";
+    return;
+  }
+  const uint8_t ip_version = first_byte >> 8;
+  absl::string_view quic_packet;
+  quiche::QuicheIpAddress server_ip;
+  if (ip_version == 6) {
+    if (!reader.Seek(5)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP IPv6 start";
+      return;
+    }
+    uint8_t next_header = 0;
+    if (!reader.ReadUInt8(&next_header)) {
+      QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP next header";
+      return;
+    }
+    if (next_header != 17) {
+      // Note that this drops packets with IPv6 extension headers, since we
+      // do not expect to see them in practice.
+      QUIC_DLOG(ERROR)
+          << "Dropping CONNECT-IP packet with unexpected next header "
+          << static_cast<int>(next_header);
+      return;
+    }
+    if (!reader.Seek(1)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP hop limit";
+      return;
+    }
+    absl::string_view source_ip;
+    if (!reader.ReadBytes(&source_ip, 16)) {
+      QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP source IPv6";
+      return;
+    }
+    server_ip.FromPackedString(source_ip.data(), source_ip.length());
+    if (!reader.Seek(16)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP destination IPv6";
+      return;
+    }
+  } else if (ip_version == 4) {
+    uint8_t ihl = first_byte & 0xF;
+    if (ihl < 5) {
+      QUICHE_DLOG(ERROR) << "Dropping CONNECT-IP packet with invalid IHL "
+                         << static_cast<int>(ihl);
+      return;
+    }
+    if (!reader.Seek(8)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP IPv4 start";
+      return;
+    }
+    uint8_t ip_proto = 0;
+    if (!reader.ReadUInt8(&ip_proto)) {
+      QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP ip_proto";
+      return;
+    }
+    if (ip_proto != 17) {
+      QUIC_DLOG(ERROR) << "Dropping CONNECT-IP packet with unexpected IP proto "
+                       << static_cast<int>(ip_proto);
+      return;
+    }
+    if (!reader.Seek(2)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP IP checksum";
+      return;
+    }
+    absl::string_view source_ip;
+    if (!reader.ReadBytes(&source_ip, 4)) {
+      QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP source IPv4";
+      return;
+    }
+    server_ip.FromPackedString(source_ip.data(), source_ip.length());
+    if (!reader.Seek(4)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP destination IPv4";
+      return;
+    }
+    uint8_t ip_options_length = (ihl - 5) * 4;
+    if (!reader.Seek(ip_options_length)) {
+      QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP IP options of length "
+                         << static_cast<int>(ip_options_length);
+      return;
+    }
+  } else {
+    QUIC_DLOG(ERROR) << "Dropping CONNECT-IP packet with unexpected IP version "
+                     << static_cast<int>(ip_version);
+    return;
+  }
+  // Parse UDP header.
+  uint16_t server_port;
+  if (!reader.ReadUInt16(&server_port)) {
+    QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP source port";
+    return;
+  }
+  if (!reader.Seek(2)) {
+    QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP destination port";
+    return;
+  }
+  uint16_t udp_length;
+  if (!reader.ReadUInt16(&udp_length)) {
+    QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP UDP length";
+    return;
+  }
+  if (!reader.Seek(2)) {
+    QUICHE_DLOG(ERROR) << "Failed to seek CONNECT-IP UDP checksum";
+    return;
+  }
+  if (!reader.ReadBytes(&quic_packet, udp_length)) {
+    QUICHE_DLOG(ERROR) << "Failed to read CONNECT-IP UDP payload";
+    return;
+  }
+  if (!reader.IsDoneReading()) {
+    QUICHE_DLOG(INFO)
+        << "Received CONNECT-IP UDP packet with extra data after payload";
+  }
+  QUIC_DLOG(INFO) << "Received CONNECT-IP encapsulated packet of length "
+                  << packet.size();
+  QuicTime now = connection()->clock()->ApproximateNow();
+  QuicReceivedPacket received_packet(quic_packet.data(), quic_packet.length(),
+                                     now);
+  QuicSocketAddress server_address = QuicSocketAddress(server_ip, server_port);
+  connection()->ProcessUdpPacket(connection()->self_address(), server_address,
+                                 received_packet);
+}
+
+void MasqueEncapsulatedClientSession::CloseIpSession(
+    const std::string& details) {
+  connection()->CloseConnection(QUIC_CONNECTION_CANCELLED, details,
+                                ConnectionCloseBehavior::SILENT_CLOSE);
+}
+
+bool MasqueEncapsulatedClientSession::OnAddressAssignCapsule(
+    const AddressAssignCapsule& capsule) {
+  for (auto assigned_address : capsule.assigned_addresses) {
+    if (assigned_address.ip_prefix.address().IsIPv4() &&
+        !local_v4_address_.IsInitialized()) {
+      QUIC_LOG(INFO)
+          << "MasqueEncapsulatedClientSession saving local IPv4 address "
+          << assigned_address.ip_prefix.address();
+      local_v4_address_ = assigned_address.ip_prefix.address();
+    } else if (assigned_address.ip_prefix.address().IsIPv6() &&
+               !local_v6_address_.IsInitialized()) {
+      QUIC_LOG(INFO)
+          << "MasqueEncapsulatedClientSession saving local IPv6 address "
+          << assigned_address.ip_prefix.address();
+      local_v6_address_ = assigned_address.ip_prefix.address();
+    }
+  }
+  return true;
+}
+
+bool MasqueEncapsulatedClientSession::OnAddressRequestCapsule(
+    const AddressRequestCapsule& /*capsule*/) {
+  return true;
+}
+
+bool MasqueEncapsulatedClientSession::OnRouteAdvertisementCapsule(
+    const RouteAdvertisementCapsule& /*capsule*/) {
+  return true;
+}
+
 }  // namespace quic
diff --git a/quiche/quic/masque/masque_encapsulated_client_session.h b/quiche/quic/masque/masque_encapsulated_client_session.h
index 16fc65e..d50e9b8 100644
--- a/quiche/quic/masque/masque_encapsulated_client_session.h
+++ b/quiche/quic/masque/masque_encapsulated_client_session.h
@@ -19,7 +19,8 @@
 // sessions can coexist inside a MASQUE session.
 class QUIC_NO_EXPORT MasqueEncapsulatedClientSession
     : public QuicSpdyClientSession,
-      public MasqueClientSession::EncapsulatedClientSession {
+      public MasqueClientSession::EncapsulatedClientSession,
+      public MasqueClientSession::EncapsulatedIpSession {
  public:
   // Takes ownership of |connection|, but not of |crypto_config| or
   // |push_promise_index| or |masque_client_session|. All pointers must be
@@ -47,12 +48,27 @@
       QuicErrorCode error, const std::string& details,
       ConnectionCloseBehavior connection_close_behavior) override;
 
+  // From MasqueClientSession::EncapsulatedIpSession.
+  void ProcessIpPacket(absl::string_view packet) override;
+  void CloseIpSession(const std::string& details) override;
+  bool OnAddressAssignCapsule(const AddressAssignCapsule& capsule) override;
+  bool OnAddressRequestCapsule(const AddressRequestCapsule& capsule) override;
+  bool OnRouteAdvertisementCapsule(
+      const RouteAdvertisementCapsule& capsule) override;
+
   // From QuicSession.
   void OnConnectionClosed(const QuicConnectionCloseFrame& frame,
                           ConnectionCloseSource source) override;
 
+  // For CONNECT-IP.
+  QuicIpAddress local_v4_address() const { return local_v4_address_; }
+  QuicIpAddress local_v6_address() const { return local_v6_address_; }
+
  private:
   MasqueClientSession* masque_client_session_;  // Unowned.
+  // For CONNECT-IP.
+  QuicIpAddress local_v4_address_;
+  QuicIpAddress local_v6_address_;
 };
 
 }  // namespace quic
diff --git a/quiche/quic/masque/masque_utils.cc b/quiche/quic/masque/masque_utils.cc
index d09fc49..2bab26b 100644
--- a/quiche/quic/masque/masque_utils.cc
+++ b/quiche/quic/masque/masque_utils.cc
@@ -32,6 +32,8 @@
       return "Invalid";
     case MasqueMode::kOpen:
       return "Open";
+    case MasqueMode::kConnectIp:
+      return "CONNECT-IP";
   }
   return absl::StrCat("Unknown(", static_cast<int>(masque_mode), ")");
 }
diff --git a/quiche/quic/masque/masque_utils.h b/quiche/quic/masque/masque_utils.h
index 12faae0..e55b808 100644
--- a/quiche/quic/masque/masque_utils.h
+++ b/quiche/quic/masque/masque_utils.h
@@ -27,9 +27,13 @@
 enum class MasqueMode : uint8_t {
   kInvalid = 0,  // Should never be used.
   kOpen = 2,  // Open mode uses the MASQUE HTTP CONNECT-UDP method as documented
-  // in <https://tools.ietf.org/html/draft-ietf-masque-connect-udp>. This mode
-  // allows unauthenticated clients (a more restricted mode will be added to
-  // this enum at a later date).
+  // in <https://www.rfc-editor.org/rfc/rfc9298.html>. This mode allows
+  // unauthenticated clients (a more restricted mode will be added to this enum
+  // at a later date).
+  kConnectIp =
+      1,  // ConnectIp mode uses MASQUE HTTP CONNECT-IP as documented in
+  // <https://datatracker.ietf.org/doc/html/draft-ietf-masque-connect-ip>. This
+  // mode also allows unauthenticated clients.
 };
 
 QUIC_NO_EXPORT std::string MasqueModeToString(MasqueMode masque_mode);