From afe3aaf0207d8ca833dccbbca841aeb9527c010f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 23:35:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(quic):=20RFC=209000=20=C2=A78.2=20server-i?= =?UTF-8?q?nitiated=20path=20validation=20(PATH=5FCHALLENGE=20/=20PATH=5FR?= =?UTF-8?q?ESPONSE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soak target #4 — minimum viable path validation. Lands the spec-required peer-initiated case so a server probing the path (e.g. after a NAT rebind, or post-CID rotation) sees a matching PATH_RESPONSE and doesn't declare the path dead. Pre-fix the parser decoded PATH_CHALLENGE / PATH_RESPONSE bytes but threw the result away — a peer's challenge went silently into the void. After ~3 RTT of no response, a strict peer would mark the path dead and tear the connection down (visible to audio-rooms users as a sudden cut on a phone that briefly switched cells). Implementation: - Add PathChallengeFrame / PathResponseFrame data classes; wire decode and encode (was decode-and-discard previously). - Add `pendingPathResponses` queue on QuicConnection (bounded at MAX_PENDING_PATH_RESPONSES = 64 to defend against challenge flood; excess silently dropped — peer retries on PTO). - Parser handler queues a response on inbound PATH_CHALLENGE. - Writer drains the queue in buildApplicationPacket. RFC 9000 §13.3 doesn't list PATH_RESPONSE as ack-eliciting-and- retransmittable; if a response is lost, the peer's next PATH_CHALLENGE re-queues it and we respond again. Out of scope for this landing (multi-day each, parked unless production evidence requires): - Client-initiated migration: requires UdpSocket replacement, new-CID acquisition tracking, validating new path BEFORE moving traffic to it. - Anti-amplification on unvalidated paths (RFC 9000 §8.1). Tests (PathValidationTest, 6 cases): - PATH_CHALLENGE / PATH_RESPONSE codec round-trip + 8-byte length validation. - End-to-end: peer PATH_CHALLENGE → client PATH_RESPONSE with byte-equal payload. - Multi-challenge fan-in: 3 challenges → 3 distinct responses (in any order; matched by content). - Flood cap: 256 challenges → ≤ MAX_PENDING_PATH_RESPONSES responses, connection stays CONNECTED. https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec --- .../quic/connection/QuicConnection.kt | 61 +++++ .../quic/connection/QuicConnectionParser.kt | 35 +++ .../quic/connection/QuicConnectionWriter.kt | 13 + .../com/vitorpamplona/quic/frame/Frame.kt | 52 +++- .../quic/connection/PathValidationTest.kt | 250 ++++++++++++++++++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PathValidationTest.kt diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index e506319eb..84f7e5611 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -480,6 +480,31 @@ class QuicConnection( internal val pendingNewConnectionId: MutableMap = HashMap() + /** + * RFC 9000 §8.2.2: queue of inbound PATH_CHALLENGE payloads we + * still owe a PATH_RESPONSE for. Each entry is the EXACT 8 bytes + * the peer challenged with — the response MUST echo them + * unchanged. The writer drains this queue on the next + * application-level packet build, emitting one PATH_RESPONSE + * per entry until empty. + * + * Bounded at [MAX_PENDING_PATH_RESPONSES] to defend against an + * attacker spamming PATH_CHALLENGE frames to exhaust memory. + * Excess challenges are dropped; the protocol allows it (a peer + * that doesn't get a response just retries). + * + * RFC 9000 §8.2.1 nuance: the response MUST go out on the + * incoming-packet's path. We model a single path today (one + * UDP socket per connection — see [com.vitorpamplona.quic.transport.UdpSocket]'s + * "no migration" kdoc), so "path the challenge arrived on" is + * trivially "the only path." When client-initiated migration + * lands, this queue grows to remember which path each entry + * belongs to. + * + * Caller must hold [streamsLock] for any read/write. + */ + internal val pendingPathResponses: ArrayDeque = ArrayDeque() + /** * RFC 9002 RTT estimator + loss-detection algorithm. Single * shared instance per connection (RTT is per-path; we model a @@ -1856,6 +1881,29 @@ class QuicConnection( } } + /** + * Queue a PATH_RESPONSE for the given [challengeData]. Called by + * the parser when a PATH_CHALLENGE arrives. Idempotent on + * duplicate challenges (peer retransmit) — the writer drains + * each entry exactly once, so an over-eager peer that sends + * many PATH_CHALLENGEs gets many PATH_RESPONSEs (RFC 9000 §8.2 + * permits this; the spec just requires at-least-one). + * + * The queue is bounded at [MAX_PENDING_PATH_RESPONSES]; excess + * entries are silently dropped (the protocol allows the peer + * to time out and retry). + * + * Caller must hold [streamsLock]. + */ + internal fun queuePathResponseLocked(challengeData: ByteArray) { + if (pendingPathResponses.size >= MAX_PENDING_PATH_RESPONSES) return + // Defensive copy: the parser hands us a slice of the inbound + // packet's plaintext payload, which the parser may free / + // reuse after we return. Copying preserves the bytes for the + // writer to encode later. + pendingPathResponses.addLast(challengeData.copyOf()) + } + /** * True if [streamId] has been retired *and* is still inside the * ring's eviction window. Used by [QuicConnectionParser] to drop @@ -2072,6 +2120,19 @@ class QuicConnection( * the per-stream object size we're saving by retiring. */ const val RETIRED_STREAM_ID_RING_SIZE: Int = 4_096 + + /** + * Bound on the [pendingPathResponses] queue. RFC 9000 §8.2 + * doesn't cap PATH_CHALLENGE rate, so a malicious peer could + * spam them to exhaust our memory. 64 entries × 8 bytes = 512 B + * worst case — trivial to absorb but tight enough that an + * attacker can't pin 100 MB by flooding. + * + * Excess challenges are dropped; the spec allows it (a peer + * that doesn't see a response will retransmit on the next + * PTO if path validation matters to them). + */ + const val MAX_PENDING_PATH_RESPONSES: Int = 64 } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index be1632c3e..b75013109 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quic.frame.MaxStreamDataFrame import com.vitorpamplona.quic.frame.MaxStreamsFrame import com.vitorpamplona.quic.frame.NewConnectionIdFrame import com.vitorpamplona.quic.frame.NewTokenFrame +import com.vitorpamplona.quic.frame.PathChallengeFrame +import com.vitorpamplona.quic.frame.PathResponseFrame import com.vitorpamplona.quic.frame.PingFrame import com.vitorpamplona.quic.frame.ResetStreamFrame import com.vitorpamplona.quic.frame.StopSendingFrame @@ -710,6 +712,39 @@ private fun dispatchFrames( ackEliciting = true } + is PathChallengeFrame -> { + // RFC 9000 §8.2.2 — peer is validating that a path is + // alive. We MUST echo the SAME 8-byte payload in a + // PATH_RESPONSE on the path the challenge arrived on. + // The writer drains [pendingPathResponses] on the next + // application-level packet build. + // + // Common practical trigger: server-side path + // validation after our connection-id rotation, OR + // post-NAT-rebind probing. Without responding the + // server may declare the path dead within a few RTTs + // and tear the connection down — visible to users as + // a sudden audio cut on a phone that briefly + // switched cells. + // + // RFC 9000 §13.2.1: PATH_CHALLENGE is ack-eliciting. + // The PATH_RESPONSE we queue here is itself + // ack-eliciting; the regular ACK path covers both. + ackEliciting = true + conn.queuePathResponseLocked(frame.data) + } + + is PathResponseFrame -> { + // RFC 9000 §13.2.1: PATH_RESPONSE is ack-eliciting. + // We don't yet issue PATH_CHALLENGE ourselves (that's + // the client-initiated migration path, out of scope + // for the first-pass landing here), so any PATH_RESPONSE + // we receive is necessarily for a challenge we never + // sent — drop it after marking ack-eliciting so the + // outbound ACK still goes out. + ackEliciting = true + } + is ConnectionCloseFrame -> { // Audit-4 #13: any frames following CONNECTION_CLOSE in the // same payload MUST NOT be dispatched — they could create diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 728bf6967..e94944e8c 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quic.frame.MaxDataFrame import com.vitorpamplona.quic.frame.MaxStreamDataFrame import com.vitorpamplona.quic.frame.MaxStreamsFrame import com.vitorpamplona.quic.frame.NewConnectionIdFrame +import com.vitorpamplona.quic.frame.PathResponseFrame import com.vitorpamplona.quic.frame.PingFrame import com.vitorpamplona.quic.frame.ResetStreamFrame import com.vitorpamplona.quic.frame.StopSendingFrame @@ -949,6 +950,18 @@ private fun appendFlowControlUpdates( } } + // PATH_RESPONSE — drain every pending challenge response in one + // pass. RFC 9000 §13.3 is silent on retransmission for + // PATH_RESPONSE: it's NOT in the ack-eliciting-and-retransmittable + // class, so we don't track tokens for these. If the response + // packet is lost, the peer's next PATH_CHALLENGE retry queues a + // fresh entry here and we respond again. The peer is responsible + // for retrying its challenge until it sees a matching response. + while (conn.pendingPathResponses.isNotEmpty()) { + val data = conn.pendingPathResponses.removeFirst() + frames += PathResponseFrame(data) + } + // NEW_CONNECTION_ID retransmits. No application path emits these // initially today (connection-ID rotation isn't wired); the map // is populated only by the loss dispatcher, so this branch only diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt index d61fd774f..965b0e529 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt @@ -313,6 +313,54 @@ class NewConnectionIdFrame( } } +/** + * RFC 9000 §19.17 — PATH_CHALLENGE frame, used for path validation + * (§8.2). The 8-byte [data] payload is opaque random bytes the + * sender uses to bind a PATH_RESPONSE back to a specific + * challenge. The receiver MUST echo the SAME 8 bytes in a + * [PathResponseFrame] on the path the challenge arrived on. + * + * Path validation lets either endpoint confirm the peer can still + * receive on a 4-tuple: the most common practical use is the + * server probing the client after a NAT rebind / connection + * migration. Without responding, the server may declare the path + * dead and tear the connection down — visible to users as a + * sudden audio cut on a phone that briefly switched cells. + */ +class PathChallengeFrame( + val data: ByteArray, +) : Frame() { + init { + require(data.size == 8) { "PATH_CHALLENGE data must be exactly 8 bytes per RFC 9000 §19.17" } + } + + override fun encode(out: QuicWriter) { + out.writeByte(FrameType.PATH_CHALLENGE.toInt()) + out.writeBytes(data) + } +} + +/** + * RFC 9000 §19.18 — PATH_RESPONSE frame, the reply to a + * [PathChallengeFrame]. Carries the EXACT same 8-byte payload back. + * The challenger uses byte-equality to match a response to its + * outstanding challenge — a peer that echoes random bytes would + * pass validation, so callers that issue PATH_CHALLENGE MUST use + * a cryptographically-random payload. + */ +class PathResponseFrame( + val data: ByteArray, +) : Frame() { + init { + require(data.size == 8) { "PATH_RESPONSE data must be exactly 8 bytes per RFC 9000 §19.18" } + } + + override fun encode(out: QuicWriter) { + out.writeByte(FrameType.PATH_RESPONSE.toInt()) + out.writeBytes(data) + } +} + /** * Decode a stream of frames from [data]. Padding bytes (0x00) are silently * absorbed. Unknown frame types raise [QuicCodecException] (per RFC 9000 §19 @@ -445,11 +493,11 @@ fun decodeFrames(data: ByteArray): List { } type == FrameType.PATH_CHALLENGE -> { - r.readBytes(8) + out += PathChallengeFrame(r.readBytes(8)) } type == FrameType.PATH_RESPONSE -> { - r.readBytes(8) + out += PathResponseFrame(r.readBytes(8)) } type == FrameType.CONNECTION_CLOSE_TRANSPORT -> { diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PathValidationTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PathValidationTest.kt new file mode 100644 index 000000000..25dfd9024 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PathValidationTest.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.connection + +import com.vitorpamplona.quic.frame.PathChallengeFrame +import com.vitorpamplona.quic.frame.PathResponseFrame +import com.vitorpamplona.quic.frame.decodeFrames +import com.vitorpamplona.quic.frame.encodeFrames +import com.vitorpamplona.quic.tls.InProcessTlsServer +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * RFC 9000 §8.2 path validation — minimum-viable peer-initiated case. + * + * Soak target #4 from the audio-rooms hardening pass: support + * the spec-required PATH_CHALLENGE / PATH_RESPONSE round-trip. + * + * Scope of this landing: + * - Frame codec for PATH_CHALLENGE (0x1A) and PATH_RESPONSE + * (0x1B) — was previously decoded-and-discarded. + * - Server-initiated path validation: peer sends + * PATH_CHALLENGE; client MUST echo the SAME 8-byte payload + * in a PATH_RESPONSE on the next outbound packet. + * + * Out of scope (explicit follow-on): + * - Client-initiated migration: requires UdpSocket replacement, + * new-CID acquisition tracking, and validating the new path + * BEFORE moving traffic to it. + * - Anti-amplification on unvalidated paths (RFC 9000 §8.1). + * + * Why this matters even without client-initiated migration: any + * compliant peer (server or middlebox) MAY probe the path at any + * time — most commonly after a NAT rebind or our connection-id + * rotation. A peer that doesn't see a PATH_RESPONSE within a few + * RTTs may declare the path dead and tear the connection down, + * visible to users as a sudden audio cut on a phone that briefly + * switched cells. + */ +class PathValidationTest { + @Test + fun pathChallengeFrameRoundTripsThroughCodec() { + val payload = byteArrayOf(0x01, 0x23, 0x45, 0x67, -0x77, -0x55, -0x33, -0x11) + val encoded = encodeFrames(listOf(PathChallengeFrame(payload))) + val decoded = decodeFrames(encoded) + assertEquals(1, decoded.size) + val frame = decoded.first() as PathChallengeFrame + assertContentEquals( + payload, + frame.data, + "PATH_CHALLENGE codec must round-trip the 8-byte payload byte-for-byte", + ) + } + + @Test + fun pathResponseFrameRoundTripsThroughCodec() { + val payload = byteArrayOf(-0x80, 0x7F, 0x00, -0x01, 0x55, -0x56, 0x42, -0x43) + val encoded = encodeFrames(listOf(PathResponseFrame(payload))) + val decoded = decodeFrames(encoded) + assertEquals(1, decoded.size) + val frame = decoded.first() as PathResponseFrame + assertContentEquals(payload, frame.data) + } + + @Test + fun pathChallengeFrameRejectsNonEightByteData() { + try { + PathChallengeFrame(ByteArray(7)) + error("PATH_CHALLENGE constructor must reject < 8 bytes") + } catch (_: IllegalArgumentException) { + // expected + } + try { + PathChallengeFrame(ByteArray(9)) + error("PATH_CHALLENGE constructor must reject > 8 bytes") + } catch (_: IllegalArgumentException) { + // expected + } + } + + @Test + fun inboundPathChallengeQueuesMatchingPathResponse() = + runBlocking { + val (client, pipe) = newConnectedClient() + val challengeData = byteArrayOf(0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte(), 0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + + // Server sends a PATH_CHALLENGE in a 1-RTT packet. Pre-fix + // the client would silently absorb it — the connection + // would stay CONNECTED but the peer would never see a + // PATH_RESPONSE, eventually declaring the path dead. + val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(challengeData)))!! + feedDatagram(client, packet, nowMillis = 0L) + + // Drain the next outbound packet and verify it carries a + // PATH_RESPONSE with the EXACT same 8 bytes. + val outbound = drainOutbound(client, nowMillis = 0L) + assertTrue(outbound != null, "client must emit an outbound packet (ACK + PATH_RESPONSE)") + val frames = pipe.decryptClientApplicationFrames(outbound) + assertTrue(frames != null, "outbound packet must decrypt with the live application keys") + val response = frames.firstOrNull { it is PathResponseFrame } as? PathResponseFrame + assertTrue(response != null, "outbound packet must contain a PATH_RESPONSE — got ${frames.map { it::class.simpleName }}") + assertContentEquals( + challengeData, + response.data, + "PATH_RESPONSE MUST echo the PATH_CHALLENGE payload exactly (byte equality is " + + "the discriminator the peer uses to match a response to its outstanding challenge)", + ) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + } + + @Test + fun multipleQueuedPathChallengesDrainAsMultipleResponses() = + runBlocking { + // RFC 9000 §8.2: a peer MAY send several PATH_CHALLENGEs + // (e.g. validation retries on packet loss). Each one + // requires its own PATH_RESPONSE — a single response + // doesn't subsume earlier ones because the payload bytes + // are independent random values. + val (client, pipe) = newConnectedClient() + val challenges = + listOf( + ByteArray(8) { 0x10.toByte() }, + ByteArray(8) { 0x20.toByte() }, + ByteArray(8) { 0x30.toByte() }, + ) + for (data in challenges) { + val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(data)))!! + feedDatagram(client, packet, nowMillis = 0L) + } + + // Drain all outbound packets and collect every PATH_RESPONSE + // we see. The writer can fold all three into one packet + // (each is just 9 wire bytes) but might also split — either + // shape is spec-correct. + val responses = mutableListOf() + while (true) { + val out = drainOutbound(client, nowMillis = 0L) ?: break + val frames = pipe.decryptClientApplicationFrames(out) ?: continue + for (f in frames) { + if (f is PathResponseFrame) responses += f + } + } + assertEquals( + challenges.size, + responses.size, + "client must emit exactly one PATH_RESPONSE per inbound PATH_CHALLENGE", + ) + // The responses can arrive in any order; match by content. + val responseSet = responses.map { it.data.toList() }.toSet() + val challengeSet = challenges.map { it.toList() }.toSet() + assertEquals( + challengeSet, + responseSet, + "every challenge payload must appear in some response", + ) + } + + @Test + fun pathResponseQueueIsBoundedAgainstChallengeFlood() = + runBlocking { + // Defence-in-depth: an attacker spamming PATH_CHALLENGE + // shouldn't pin arbitrary memory in our pendingPathResponses + // queue. Cap is MAX_PENDING_PATH_RESPONSES (64); excess + // challenges are silently dropped — the protocol allows + // it (peer would retransmit on PTO if a response actually + // mattered). + val (client, pipe) = newConnectedClient() + val flood = QuicConnection.MAX_PENDING_PATH_RESPONSES * 4 + for (i in 0 until flood) { + val data = ByteArray(8) { ((i shr 8) and 0xFF).toByte() } + data[7] = (i and 0xFF).toByte() + val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(data)))!! + feedDatagram(client, packet, nowMillis = 0L) + } + + val responses = mutableListOf() + while (true) { + val out = drainOutbound(client, nowMillis = 0L) ?: break + val frames = pipe.decryptClientApplicationFrames(out) ?: continue + for (f in frames) { + if (f is PathResponseFrame) responses += f + } + } + assertTrue( + responses.size <= QuicConnection.MAX_PENDING_PATH_RESPONSES, + "response count ${responses.size} must not exceed cap " + + "${QuicConnection.MAX_PENDING_PATH_RESPONSES}", + ) + // And: connection survives the flood. + assertEquals(QuicConnection.Status.CONNECTED, client.status) + } + + private fun newConnectedClient(): Pair = + runBlocking { + val client = + QuicConnection( + serverName = "path.test", + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + val serverScid = ConnectionId.random(8) + val tlsServer = + InProcessTlsServer( + transportParameters = + TransportParameters( + initialMaxData = 1L * 1024 * 1024, + initialMaxStreamDataBidiLocal = 64L * 1024, + initialMaxStreamDataBidiRemote = 64L * 1024, + initialMaxStreamDataUni = 64L * 1024, + initialMaxStreamsBidi = 16, + initialMaxStreamsUni = 16, + initialSourceConnectionId = serverScid.bytes, + originalDestinationConnectionId = client.destinationConnectionId.bytes, + ).encode(), + ) + val pipe = + InMemoryQuicPipe( + client = client, + initialDcid = client.destinationConnectionId.bytes, + serverScid = serverScid, + tlsServer = tlsServer, + ) + client.start() + pipe.drive(maxRounds = 16) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + client to pipe + } +}