Skip to content

Commit 4080cd1

Browse files
committed
fix: do not coalesce udp payloads
1 parent 567ce15 commit 4080cd1

5 files changed

Lines changed: 562 additions & 13 deletions

File tree

app/src/main/java/tech/httptoolkit/android/vpn/Session.java

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.nio.ByteBuffer;
3333
import java.nio.channels.SelectionKey;
3434
import java.nio.channels.spi.AbstractSelectableChannel;
35+
import java.util.ArrayDeque;
3536

3637
/**
3738
* store information about a socket connection from a VPN client.
@@ -73,8 +74,12 @@ public class Session {
7374
//receiving buffer for storing data from remote host
7475
private final ByteArrayOutputStream receivingStream;
7576

76-
//sending buffer for storing data from vpn client to be send to destination host
77+
//sending buffer for storing data from vpn client to be send to destination host (TCP only)
7778
private final ByteArrayOutputStream sendingStream;
79+
80+
//queue of discrete datagrams to be sent to the destination host (UDP only). UDP must
81+
//preserve datagram boundaries, so unlike TCP it cannot use a flat byte stream.
82+
private final ArrayDeque<byte[]> sendingDatagrams = new ArrayDeque<>();
7883

7984
private boolean hasReceivedLastSegment = false;
8085

@@ -171,13 +176,21 @@ public boolean hasReceivedData(){
171176
}
172177

173178
/**
174-
* set data to be sent to destination server
179+
* set data to be sent to destination server.
180+
* For UDP each call is queued as a discrete datagram
181+
* For TCP the bytes are appended to the send stream.
175182
* @param data Data to be sent
176-
* @return boolean Success or not
183+
* @return int number of bytes accepted
177184
*/
178185
public synchronized int setSendingData(ByteBuffer data) {
179186
final int remaining = data.remaining();
180-
sendingStream.write(data.array(), data.position(), data.remaining());
187+
if (protocol == SessionProtocol.UDP) {
188+
byte[] datagram = new byte[remaining];
189+
System.arraycopy(data.array(), data.position(), datagram, 0, remaining);
190+
sendingDatagrams.addLast(datagram);
191+
} else {
192+
sendingStream.write(data.array(), data.position(), remaining);
193+
}
181194
return remaining;
182195
}
183196

@@ -186,20 +199,38 @@ int getSendingDataSize(){
186199
}
187200

188201
/**
189-
* dequeue data for sending to server
202+
* dequeue all stream data for sending to the server (TCP).
190203
* @return byte[]
191204
*/
192205
public synchronized byte[] getSendingData(){
193206
byte[] data = sendingStream.toByteArray();
194207
sendingStream.reset();
195208
return data;
196209
}
210+
211+
/**
212+
* dequeue the next datagram for sending to the server (UDP), or null if none remain.
213+
* @return byte[]
214+
*/
215+
public synchronized byte[] pollSendingDatagram(){
216+
return sendingDatagrams.pollFirst();
217+
}
218+
219+
/**
220+
* return a datagram to the head of the queue when it could not be written yet (UDP).
221+
*/
222+
public synchronized void requeueSendingDatagram(byte[] datagram){
223+
sendingDatagrams.addFirst(datagram);
224+
}
225+
197226
/**
198227
* buffer contains data for sending to destination server
199228
* @return boolean
200229
*/
201-
public boolean hasDataToSend(){
202-
return sendingStream.size() > 0;
230+
public synchronized boolean hasDataToSend(){
231+
return protocol == SessionProtocol.UDP
232+
? !sendingDatagrams.isEmpty()
233+
: sendingStream.size() > 0;
203234
}
204235

205236
public SessionProtocol getProtocol() {

app/src/main/java/tech/httptoolkit/android/vpn/socket/SocketChannelWriter.java

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public long write(@NonNull Session session) {
8080
private long writeUDP(Session session) {
8181
long amountBytes = 0;
8282
try {
83-
amountBytes = writePendingData(session);
83+
amountBytes = writePendingUDPData(session);
8484
Date dt = new Date();
8585
session.connectionStartTime = dt.getTime();
8686
}catch(NotYetConnectedException ex2){
@@ -117,21 +117,20 @@ private long writeTCP(Session session) {
117117
return amountBytes;
118118
}
119119

120+
/** TCP: a byte stream, so buffered bytes are concatenated and written as-is. */
120121
private long writePendingData(Session session) throws IOException {
121122
if (!session.hasDataToSend()) return 0;
122123

123124
long totalBytesWritten = 0;
124-
AbstractSelectableChannel channel = session.getChannel();
125+
SocketChannel channel = (SocketChannel) session.getChannel();
125126

126127
byte[] data = session.getSendingData();
127128
ByteBuffer buffer = ByteBuffer.allocate(data.length);
128129
buffer.put(data);
129130
buffer.flip();
130131

131132
while (buffer.hasRemaining()) {
132-
int bytesWritten = channel instanceof SocketChannel
133-
? ((SocketChannel) channel).write(buffer)
134-
: ((DatagramChannel) channel).write(buffer);
133+
int bytesWritten = channel.write(buffer);
135134

136135
if (bytesWritten == 0) {
137136
break;
@@ -149,7 +148,7 @@ private long writePendingData(Session session) throws IOException {
149148
// Subscribe to WRITE events, so we know when this is ready to resume.
150149
session.subscribeKey(SelectionKey.OP_WRITE);
151150
} else {
152-
// All done, all good -> wait until the next TCP PSH / UDP packet
151+
// All done, all good -> wait until the next TCP PSH packet
153152
session.setDataForSendingReady(false);
154153

155154
// We don't need to know about WRITE events any more, we've written all our data.
@@ -158,4 +157,39 @@ private long writePendingData(Session session) throws IOException {
158157
}
159158
return totalBytesWritten;
160159
}
160+
161+
/**
162+
* UDP: a datagram protocol, so each queued datagram must be written with its own
163+
* channel.write() to preserve message boundaries. We send one datagram per write cycle
164+
* and resubscribe to OP_WRITE while more remain, mirroring the TCP backpressure pattern.
165+
*/
166+
private long writePendingUDPData(Session session) throws IOException {
167+
byte[] datagram = session.pollSendingDatagram();
168+
if (datagram == null) {
169+
session.setDataForSendingReady(false);
170+
session.unsubscribeKey(SelectionKey.OP_WRITE);
171+
return 0;
172+
}
173+
174+
DatagramChannel channel = (DatagramChannel) session.getChannel();
175+
// A connected non-blocking DatagramChannel writes the whole datagram or nothing
176+
// (0 when the send buffer is full); it never sends a partial datagram.
177+
int bytesWritten = channel.write(ByteBuffer.wrap(datagram));
178+
179+
if (bytesWritten == 0) {
180+
// Not ready yet: put the datagram back and resume on the next OP_WRITE.
181+
session.requeueSendingDatagram(datagram);
182+
session.subscribeKey(SelectionKey.OP_WRITE);
183+
return 0;
184+
}
185+
186+
if (session.hasDataToSend()) {
187+
// More datagrams queued -> come back for the next one.
188+
session.subscribeKey(SelectionKey.OP_WRITE);
189+
} else {
190+
session.setDataForSendingReady(false);
191+
session.unsubscribeKey(SelectionKey.OP_WRITE);
192+
}
193+
return bytesWritten;
194+
}
161195
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package tech.httptoolkit.android.vpn
2+
3+
import android.app.Application
4+
import com.google.common.truth.Truth.assertThat
5+
import org.junit.After
6+
import org.junit.Before
7+
import org.junit.Test
8+
import org.junit.runner.RunWith
9+
import org.robolectric.RobolectricTestRunner
10+
import org.robolectric.annotation.Config
11+
import tech.httptoolkit.android.vpn.transport.ip.IPAddress
12+
import tech.httptoolkit.android.vpn.transport.ip.IPHeader
13+
import tech.httptoolkit.android.vpn.transport.tcp.TCPHeader
14+
import java.net.DatagramPacket
15+
import java.net.DatagramSocket
16+
import java.net.InetAddress
17+
import java.net.ServerSocket
18+
import java.net.Socket
19+
import java.util.concurrent.Executors
20+
import java.util.concurrent.TimeUnit
21+
22+
/**
23+
* End-to-end correctness checks on the engine's connection tracking, through the real
24+
* forwarding pipeline against loopback peers. Exercises both directions:
25+
*
26+
* - egress (packet intercepted from a local app): the engine creates/reuses the right
27+
* session per 5-tuple, and demultiplexes concurrent connections without crossing streams;
28+
* - ingress (data coming back from the LAN peer): each reply is routed back to the exact
29+
* client connection that originated it, and traffic for an unknown connection is rejected.
30+
*/
31+
@RunWith(RobolectricTestRunner::class)
32+
@Config(sdk = [34], application = Application::class)
33+
class ConnectionTrackingForwardingTest {
34+
35+
private lateinit var harness: ForwardingTestHarness
36+
37+
private val clientIp = "10.0.0.2"
38+
private val peerIp = "127.0.0.1"
39+
40+
@Before
41+
fun setUp() {
42+
harness = ForwardingTestHarness()
43+
}
44+
45+
@After
46+
fun tearDown() {
47+
harness.close()
48+
}
49+
50+
// --- UDP -----------------------------------------------------------------
51+
52+
@Test
53+
fun `concurrent udp connections demultiplex replies back to the originating client`() {
54+
val peer = DatagramSocket(0, InetAddress.getByName(peerIp)).apply { soTimeout = 3000 }
55+
val peerPort = peer.localPort
56+
try {
57+
// Two client connections to the same peer, distinguished only by source port.
58+
harness.feed(TestPackets.udpPacket(clientIp, 40001, peerIp, peerPort, "one".toByteArray()))
59+
harness.feed(TestPackets.udpPacket(clientIp, 40002, peerIp, peerPort, "two".toByteArray()))
60+
61+
// The peer sees two distinct source sockets; echo each payload straight back.
62+
repeat(2) {
63+
val rx = DatagramPacket(ByteArray(64), 64)
64+
peer.receive(rx)
65+
peer.send(DatagramPacket(rx.data, rx.length, rx.socketAddress))
66+
}
67+
68+
// Collect both TUN replies, then assert each landed on the correct client port:
69+
// "one" must return to 40001 and "two" to 40002 (no cross-talk).
70+
val byClientPort = buildMap {
71+
repeat(2) {
72+
val (_, udp, payload) = harness.parseUdp(harness.awaitTunPacket())
73+
put(udp.destinationPort, String(payload))
74+
}
75+
}
76+
assertThat(byClientPort).containsExactly(40001, "one", 40002, "two")
77+
} finally {
78+
peer.close()
79+
}
80+
}
81+
82+
@Test
83+
fun `repeated udp datagrams reuse one connection and accumulate egress`() {
84+
val peer = DatagramSocket(0, InetAddress.getByName(peerIp)).apply { soTimeout = 3000 }
85+
val peerPort = peer.localPort
86+
try {
87+
harness.feed(TestPackets.udpPacket(clientIp, 40003, peerIp, peerPort, "p1".toByteArray()))
88+
harness.feed(TestPackets.udpPacket(clientIp, 40003, peerIp, peerPort, "p2".toByteArray()))
89+
90+
// Both datagrams reach the peer over the same upstream socket: the second reused
91+
// the connection rather than opening a new one.
92+
val first = DatagramPacket(ByteArray(64), 64).also { peer.receive(it) }
93+
val second = DatagramPacket(ByteArray(64), 64).also { peer.receive(it) }
94+
assertThat(
95+
setOf(String(first.data, 0, first.length), String(second.data, 0, second.length))
96+
).containsExactly("p1", "p2")
97+
assertThat(second.socketAddress).isEqualTo(first.socketAddress)
98+
99+
// Exactly one session/flow was created; egress counters accumulate across both.
100+
val session = harness.await {
101+
harness.sessionByKey(udpKey(40003, peerPort))
102+
}
103+
assertThat(harness.flowDao.countNotSyncedFlows()).isEqualTo(1)
104+
harness.await { session.flow.takeIf { it.packetCountEgress >= 2 } }
105+
} finally {
106+
peer.close()
107+
}
108+
}
109+
110+
@Test
111+
fun `back-to-back udp datagrams preserve message boundaries`() {
112+
// Regression test for datagram coalescing: two datagrams sent on the same connection
113+
// before the writer drains must NOT be merged into one upstream datagram.
114+
val peer = DatagramSocket(0, InetAddress.getByName(peerIp)).apply { soTimeout = 3000 }
115+
val peerPort = peer.localPort
116+
try {
117+
harness.feed(TestPackets.udpPacket(clientIp, 40004, peerIp, peerPort, "AAAA".toByteArray()))
118+
harness.feed(TestPackets.udpPacket(clientIp, 40004, peerIp, peerPort, "BBBB".toByteArray()))
119+
120+
// The peer must receive two distinct 4-byte datagrams, not one merged "AAAABBBB".
121+
val first = DatagramPacket(ByteArray(64), 64).also { peer.receive(it) }
122+
val second = DatagramPacket(ByteArray(64), 64).also { peer.receive(it) }
123+
assertThat(first.length).isEqualTo(4)
124+
assertThat(second.length).isEqualTo(4)
125+
assertThat(listOf(String(first.data, 0, 4), String(second.data, 0, 4)))
126+
.containsExactly("AAAA", "BBBB").inOrder()
127+
} finally {
128+
peer.close()
129+
}
130+
}
131+
132+
// --- TCP -----------------------------------------------------------------
133+
134+
@Test
135+
fun `concurrent tcp connections are tracked independently`() {
136+
val server = ServerSocket(0, 50, InetAddress.getByName(peerIp))
137+
val peerPort = server.localPort
138+
val executor = Executors.newFixedThreadPool(2)
139+
val isn1 = 1000L
140+
val isn2 = 5000L
141+
try {
142+
val accept1 = executor.submit<Socket> { server.accept() }
143+
val accept2 = executor.submit<Socket> { server.accept() }
144+
145+
harness.feed(syn(40001, peerPort, isn1))
146+
harness.feed(syn(40002, peerPort, isn2))
147+
148+
// Each SYN-ACK must be demultiplexed to its own client port and acknowledge that
149+
// connection's ISN — proving the two handshakes are not conflated.
150+
val synAcks = buildMap<Int, Pair<IPHeader, TCPHeader>> {
151+
repeat(2) {
152+
val pkt = harness.awaitTunPacketMatching {
153+
val (_, tcp) = harness.parseTcp(it); tcp.isSYN && tcp.isACK
154+
}
155+
val parsed = harness.parseTcp(pkt)
156+
put(parsed.second.destinationPort, parsed)
157+
}
158+
}
159+
160+
assertThat(synAcks.keys).containsExactly(40001, 40002)
161+
assertThat(synAcks.getValue(40001).second.ackNumber).isEqualTo(isn1 + 1)
162+
assertThat(synAcks.getValue(40002).second.ackNumber).isEqualTo(isn2 + 1)
163+
assertThat(synAcks.getValue(40001).first.destinationIP.toString()).isEqualTo(clientIp)
164+
assertThat(synAcks.getValue(40002).first.destinationIP.toString()).isEqualTo(clientIp)
165+
166+
// Two distinct sessions and two recorded flows.
167+
assertThat(harness.sessionByKey(tcpKey(40001, peerPort))).isNotNull()
168+
assertThat(harness.sessionByKey(tcpKey(40002, peerPort))).isNotNull()
169+
assertThat(harness.flowDao.countNotSyncedFlows()).isEqualTo(2)
170+
171+
accept1.get(3, TimeUnit.SECONDS)
172+
accept2.get(3, TimeUnit.SECONDS)
173+
} finally {
174+
executor.shutdownNow()
175+
server.close()
176+
}
177+
}
178+
179+
@Test
180+
fun `tcp data for an unknown connection is rejected with RST and creates no session`() {
181+
// An ACK with payload but no preceding SYN has no tracked connection: the engine must
182+
// reject it with a RST rather than silently adopting it or crashing.
183+
harness.feed(
184+
TestPackets.tcpPacket(
185+
clientIp, 40009, peerIp, 9,
186+
seq = 42, ack = 99, flags = TestPackets.ACK, payload = "junk".toByteArray(),
187+
)
188+
)
189+
190+
val rst = harness.awaitTunPacketMatching { harness.parseTcp(it).second.isRST }
191+
val (ip, tcp) = harness.parseTcp(rst)
192+
assertThat(tcp.isRST).isTrue()
193+
assertThat(ip.destinationIP.toString()).isEqualTo(clientIp)
194+
assertThat(tcp.destinationPort).isEqualTo(40009)
195+
196+
assertThat(harness.sessionByKey(tcpKey(40009, 9))).isNull()
197+
assertThat(harness.flowDao.countNotSyncedFlows()).isEqualTo(0)
198+
}
199+
200+
// --- helpers -------------------------------------------------------------
201+
202+
private fun syn(clientPort: Int, peerPort: Int, isn: Long): ByteArray =
203+
TestPackets.tcpPacket(
204+
clientIp, clientPort, peerIp, peerPort,
205+
seq = isn, ack = 0, flags = TestPackets.SYN, mss = 1460,
206+
)
207+
208+
private fun udpKey(clientPort: Int, peerPort: Int): String = Session.getSessionKey(
209+
SessionProtocol.UDP,
210+
IPAddress(TestPackets.ip(peerIp)), peerPort,
211+
IPAddress(TestPackets.ip(clientIp)), clientPort,
212+
)
213+
214+
private fun tcpKey(clientPort: Int, peerPort: Int): String = Session.getSessionKey(
215+
SessionProtocol.TCP,
216+
IPAddress(TestPackets.ip(peerIp)), peerPort,
217+
IPAddress(TestPackets.ip(clientIp)), clientPort,
218+
)
219+
}

0 commit comments

Comments
 (0)