|
| 1 | +// SPDX-License-Identifier: GPL-3.0-only |
| 2 | +// Copyright (C) 2026 The Somewhere Authors |
| 3 | + |
| 4 | +package eu.nodepass.somewhere.quic |
| 5 | + |
| 6 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 7 | +import eu.nodepass.somewhere.net.NowhereDialer |
| 8 | +import eu.nodepass.somewhere.protocol.DecodeResult |
| 9 | +import eu.nodepass.somewhere.protocol.auth.AuthTransport |
| 10 | +import eu.nodepass.somewhere.protocol.auth.Authentication |
| 11 | +import eu.nodepass.somewhere.protocol.frame.FlowCarrier |
| 12 | +import eu.nodepass.somewhere.protocol.frame.FlowHeader |
| 13 | +import eu.nodepass.somewhere.protocol.frame.FlowKind |
| 14 | +import eu.nodepass.somewhere.protocol.frame.FlowRole |
| 15 | +import eu.nodepass.somewhere.protocol.frame.SetupResult |
| 16 | +import eu.nodepass.somewhere.protocol.session.NowhereSession |
| 17 | +import eu.nodepass.somewhere.protocol.session.SessionId |
| 18 | +import eu.nodepass.somewhere.protocol.session.SplitCarrier |
| 19 | +import eu.nodepass.somewhere.protocol.session.Transport |
| 20 | +import eu.nodepass.somewhere.protocol.target.Target |
| 21 | +import eu.nodepass.somewhere.protocol.url.NextHopCarrier |
| 22 | +import eu.nodepass.somewhere.protocol.url.NowhereUrl |
| 23 | +import eu.nodepass.somewhere.vpn.E2eEnvironment |
| 24 | +import org.junit.Assert.assertEquals |
| 25 | +import org.junit.Assert.assertNotNull |
| 26 | +import org.junit.Assert.assertTrue |
| 27 | +import org.junit.Test |
| 28 | +import org.junit.runner.RunWith |
| 29 | +import java.net.InetSocketAddress |
| 30 | + |
| 31 | +/** |
| 32 | + * Split flows, and the three rejections that only they can provoke. |
| 33 | + * |
| 34 | + * ## What was unreachable before this |
| 35 | + * |
| 36 | + * `PAIR_TIMEOUT`, `METADATA_CONFLICT` and `SESSION_REPLACED` are three of the |
| 37 | + * seven `SetupResult` values, and the L1 coverage map has said "unreachable at |
| 38 | + * this layer" about them since it was written. They are unreachable because |
| 39 | + * each is a Portal's answer to something only two lanes can express: a half |
| 40 | + * that never arrived, two halves that disagree, and a session claimed twice. |
| 41 | + * Their *messages* have been tested since L1. Their *provocation* needs this. |
| 42 | + * |
| 43 | + * The Portal these run against is configured with a two-second pairing timeout |
| 44 | + * (`NOW_FLOW_PAIR_TIMEOUT=2s`); the default is fifteen, which is a long time to |
| 45 | + * hold a test suite waiting for a deadline to pass. |
| 46 | + */ |
| 47 | +@RunWith(AndroidJUnit4::class) |
| 48 | +class QuicSplitFlowTest { |
| 49 | + @Test |
| 50 | + fun aSplitFlowCarriesPayloadWithTheUplinkOnQuic() { |
| 51 | + withSplitSession(up = NextHopCarrier.Udp, down = NextHopCarrier.Tcp) { session -> |
| 52 | + assertEquals("the echo did not survive the split flow", ECHO, roundTrip(session)) |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + @Test |
| 57 | + fun aSplitFlowCarriesPayloadWithTheUplinkOnTls() { |
| 58 | + withSplitSession(up = NextHopCarrier.Tcp, down = NextHopCarrier.Udp) { session -> |
| 59 | + assertEquals("the echo did not survive the split flow", ECHO, roundTrip(session)) |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * `PAIR_TIMEOUT`: the matching half arrived too late. |
| 65 | + * |
| 66 | + * Provoked by sending OPEN, waiting past the Portal's pairing deadline, and |
| 67 | + * only then sending ATTACH. The result cannot be delivered before ATTACH |
| 68 | + * exists — the uplink is a lane the client writes and never reads — so the |
| 69 | + * rejection is what ATTACH receives rather than something OPEN is told. |
| 70 | + */ |
| 71 | + @Test |
| 72 | + fun pairTimeoutIsProvokedByAnAttachThatArrivesTooLate() { |
| 73 | + assertEquals( |
| 74 | + SetupResult.PairTimeout, |
| 75 | + rejectionFrom { lanes -> |
| 76 | + lanes.sendOpen() |
| 77 | + Thread.sleep(PAIR_WAIT) |
| 78 | + lanes.sendAttach() |
| 79 | + }, |
| 80 | + ) |
| 81 | + } |
| 82 | + |
| 83 | + /** `METADATA_CONFLICT`: the two halves disagree about what the flow is. */ |
| 84 | + @Test |
| 85 | + fun metadataConflictIsProvokedByHalvesThatDisagreeAboutKind() { |
| 86 | + assertEquals( |
| 87 | + SetupResult.MetadataConflict, |
| 88 | + rejectionFrom { lanes -> |
| 89 | + lanes.sendOpen(kind = FlowKind.Tcp) |
| 90 | + lanes.sendAttach(kind = FlowKind.Udp) |
| 91 | + }, |
| 92 | + ) |
| 93 | + } |
| 94 | + |
| 95 | + /** |
| 96 | + * `SESSION_REPLACED`: the session state this flow was waiting on is gone. |
| 97 | + * |
| 98 | + * The first shape tried was to replace the session and then open a fresh |
| 99 | + * flow on the older carrier. That does not work, and the reason is worth |
| 100 | + * keeping: **this Portal implements replacement by tearing the older |
| 101 | + * carrier down**, so the older connection is draining before it can ask |
| 102 | + * anything and the answer is `ERR_DRAINING` rather than a setup byte. |
| 103 | + * |
| 104 | + * The shape that does work is the one the specification describes: OPEN |
| 105 | + * arrives, the session it belongs to is replaced, and then ATTACH arrives |
| 106 | + * and is told what became of it. Section 6: *"An OPEN-side rejection is |
| 107 | + * retained long enough to return the same result when ATTACH arrives."* |
| 108 | + * The downlink here is a TLS lane, which outlives the QUIC carrier it was |
| 109 | + * paired with — that independence is what makes the byte observable at all. |
| 110 | + */ |
| 111 | + @Test |
| 112 | + fun sessionReplacedIsReturnedToAnAttachWhoseSessionWasTakenOver() { |
| 113 | + val portal = E2eEnvironment.requirePortal() |
| 114 | + val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp) |
| 115 | + val sessionId = SessionId.random() |
| 116 | + val dialer = NowhereDialer(protect = { true }) |
| 117 | + |
| 118 | + connection(portal, url).use { first -> |
| 119 | + first.completeHandshake() |
| 120 | + |
| 121 | + // OPEN, on a carrier that is about to be replaced. The downlink is |
| 122 | + // a separate TLS connection and survives what happens to the QUIC |
| 123 | + // one. |
| 124 | + val up = QuicStreamTransport(first, first.openStream()) |
| 125 | + val down = (dialer.connect(url) as DecodeResult.Ok).value |
| 126 | + val orphaned = Lanes(up, down, sessionId, flowId = 21u) |
| 127 | + orphaned.sendOpen() |
| 128 | + |
| 129 | + connection(portal, url).use { second -> |
| 130 | + second.completeHandshake() |
| 131 | + val takeoverUp = QuicStreamTransport(second, second.openStream()) |
| 132 | + val takeoverDown = (dialer.connect(url) as DecodeResult.Ok).value |
| 133 | + val takeover = Lanes(takeoverUp, takeoverDown, sessionId, flowId = 22u) |
| 134 | + takeover.sendOpen() |
| 135 | + takeover.sendAttach() |
| 136 | + assertNotNull("the replacing session never authenticated", takeover.setupByte()) |
| 137 | + |
| 138 | + // Now the orphaned half asks what became of its flow. |
| 139 | + orphaned.sendAttach() |
| 140 | + val setup = orphaned.setupByte() |
| 141 | + assertNotNull("the orphaned ATTACH was answered with silence", setup) |
| 142 | + assertEquals( |
| 143 | + SetupResult.SessionReplaced, |
| 144 | + (SetupResult.decode(setup!!) as DecodeResult.Ok).value, |
| 145 | + ) |
| 146 | + runCatching { takeoverUp.close() } |
| 147 | + runCatching { takeoverDown.close() } |
| 148 | + } |
| 149 | + runCatching { up.close() } |
| 150 | + runCatching { down.close() } |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + /** |
| 155 | + * Row 7: the result arrives on the downlink and nowhere else. |
| 156 | + * |
| 157 | + * Not an ordering detail. The uplink is a lane the client writes and never |
| 158 | + * reads, so a setup byte placed there would be a byte nobody collects — and |
| 159 | + * a client that read one would be reading the first byte of payload. |
| 160 | + */ |
| 161 | + @Test |
| 162 | + fun onlyTheDownlinkReceivesTheResult() { |
| 163 | + val portal = E2eEnvironment.requirePortal() |
| 164 | + val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp) |
| 165 | + connection(portal, url).use { quic -> |
| 166 | + quic.completeHandshake() |
| 167 | + val dialer = NowhereDialer(protect = { true }) |
| 168 | + val up = QuicStreamTransport(quic, quic.openStream()) |
| 169 | + val down = (dialer.connect(url) as DecodeResult.Ok).value |
| 170 | + val lanes = Lanes(up, down, SessionId.random(), flowId = 9u) |
| 171 | + try { |
| 172 | + lanes.sendOpen() |
| 173 | + lanes.sendAttach() |
| 174 | + val setup = lanes.setupByte() |
| 175 | + assertNotNull("the downlink was not answered", setup) |
| 176 | + assertEquals(SetupResult.Ready, (SetupResult.decode(setup!!) as DecodeResult.Ok).value) |
| 177 | + |
| 178 | + // The uplink carries the request onward and answers nothing. |
| 179 | + up.setReadTimeout(UPLINK_SILENCE_MILLIS) |
| 180 | + val buffer = ByteArray(1) |
| 181 | + val read = runCatching { up.read(buffer, 0, 1) }.getOrDefault(-1) |
| 182 | + assertTrue("the uplink answered $read byte(s); only the downlink may", read <= 0) |
| 183 | + } finally { |
| 184 | + runCatching { up.close() } |
| 185 | + runCatching { down.close() } |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + // ── plumbing ──────────────────────────────────────────────────────────── |
| 191 | + |
| 192 | + /** The two lanes of one split flow, driven by hand. */ |
| 193 | + private inner class Lanes( |
| 194 | + val up: Transport, |
| 195 | + val down: Transport, |
| 196 | + val sessionId: SessionId, |
| 197 | + val flowId: UInt, |
| 198 | + ) { |
| 199 | + fun sendOpen(kind: FlowKind = FlowKind.Tcp) { |
| 200 | + val header = |
| 201 | + ( |
| 202 | + FlowHeader.forClient(FlowRole.Open, kind, FlowCarrier.Quic, FlowCarrier.TlsTcp, flowId) |
| 203 | + as DecodeResult.Ok |
| 204 | + ).value |
| 205 | + up.write(auth(up) + header.encode() + echoTarget().encode()) |
| 206 | + up.flush() |
| 207 | + } |
| 208 | + |
| 209 | + fun sendAttach(kind: FlowKind = FlowKind.Tcp) { |
| 210 | + val header = |
| 211 | + ( |
| 212 | + FlowHeader.forClient(FlowRole.Attach, kind, FlowCarrier.Quic, FlowCarrier.TlsTcp, flowId) |
| 213 | + as DecodeResult.Ok |
| 214 | + ).value |
| 215 | + down.write(auth(down) + header.encode()) |
| 216 | + down.flush() |
| 217 | + } |
| 218 | + |
| 219 | + fun setupByte(): Byte? { |
| 220 | + val buffer = ByteArray(1) |
| 221 | + return try { |
| 222 | + if (down.read(buffer, 0, 1) == 1) buffer[0] else null |
| 223 | + } catch (_: java.io.IOException) { |
| 224 | + null |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + private fun auth(transport: Transport): ByteArray = |
| 229 | + Authentication.encodeFrame( |
| 230 | + sharedKey = key(), |
| 231 | + transport = |
| 232 | + if (transport.transportKind == eu.nodepass.somewhere.protocol.session.TransportKind.Quic) { |
| 233 | + AuthTransport.Quic |
| 234 | + } else { |
| 235 | + AuthTransport.TlsTcp |
| 236 | + }, |
| 237 | + exporter = transport.exporter, |
| 238 | + sessionId = sessionId.toByteArray(), |
| 239 | + ) |
| 240 | + } |
| 241 | + |
| 242 | + private fun rejectionFrom(drive: (Lanes) -> Unit): SetupResult { |
| 243 | + val portal = E2eEnvironment.requirePortal() |
| 244 | + val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp) |
| 245 | + connection(portal, url).use { quic -> |
| 246 | + quic.completeHandshake() |
| 247 | + val dialer = NowhereDialer(protect = { true }) |
| 248 | + val up = QuicStreamTransport(quic, quic.openStream()) |
| 249 | + val down = (dialer.connect(url) as DecodeResult.Ok).value |
| 250 | + val lanes = Lanes(up, down, SessionId.random(), flowId = 1u) |
| 251 | + try { |
| 252 | + drive(lanes) |
| 253 | + val setup = lanes.setupByte() ?: throw AssertionError("the Portal answered nothing") |
| 254 | + return (SetupResult.decode(setup) as DecodeResult.Ok).value |
| 255 | + } finally { |
| 256 | + runCatching { up.close() } |
| 257 | + runCatching { down.close() } |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + private fun roundTrip(session: NowhereSession): String { |
| 263 | + val request = "GET /small.bin HTTP/1.0\r\n\r\n".toByteArray(Charsets.US_ASCII) |
| 264 | + val opened = session.openFlow(echoTarget(), FlowKind.Tcp, firstPayload = request) |
| 265 | + val flow = |
| 266 | + (opened as? DecodeResult.Ok)?.value |
| 267 | + ?: throw AssertionError("the split flow failed: ${opened.reasonOrNull()?.detail}") |
| 268 | + flow.use { |
| 269 | + val buffer = ByteArray(4096) |
| 270 | + val read = flow.read(buffer) |
| 271 | + assertTrue("nothing came back on the downlink", read > 0) |
| 272 | + return String(buffer, 0, minOf(read, ECHO.length), Charsets.ISO_8859_1) |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + private fun withSplitSession( |
| 277 | + up: NextHopCarrier, |
| 278 | + down: NextHopCarrier, |
| 279 | + body: (NowhereSession) -> Unit, |
| 280 | + ) { |
| 281 | + val portal = E2eEnvironment.requirePortal() |
| 282 | + val url = node(portal, up, down) |
| 283 | + connection(portal, url).use { quic -> |
| 284 | + quic.completeHandshake() |
| 285 | + val dialer = NowhereDialer(protect = { true }) |
| 286 | + |
| 287 | + fun lane(carrier: NextHopCarrier): SplitCarrier.LaneFactory = |
| 288 | + when (carrier) { |
| 289 | + NextHopCarrier.Udp -> SplitCarrier.LaneFactory { QuicStreamTransport(quic, quic.openStream()) } |
| 290 | + NextHopCarrier.Tcp -> |
| 291 | + SplitCarrier.LaneFactory { (dialer.connect(url) as DecodeResult.Ok).value } |
| 292 | + } |
| 293 | + NowhereSession( |
| 294 | + sharedKey = url.sharedKey, |
| 295 | + connect = { error("a split session dials through its lane factories") }, |
| 296 | + splitUplink = lane(up), |
| 297 | + splitDownlink = lane(down), |
| 298 | + ).use(body) |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + private fun connection( |
| 303 | + portal: String, |
| 304 | + url: NowhereUrl, |
| 305 | + ): QuicConnection = |
| 306 | + QuicConnection.open( |
| 307 | + remote = InetSocketAddress(portal.substringBeforeLast(':'), portal.substringAfterLast(':').toInt()), |
| 308 | + alpn = url.alpn, |
| 309 | + serverName = null, |
| 310 | + protect = { true }, |
| 311 | + ) |
| 312 | + |
| 313 | + private fun node( |
| 314 | + portal: String, |
| 315 | + up: NextHopCarrier, |
| 316 | + down: NextHopCarrier, |
| 317 | + ): NowhereUrl { |
| 318 | + val text = "nowhere://${E2eEnvironment.sharedKey}@$portal?up=${up.token}&down=${down.token}" |
| 319 | + return when (val parsed = NowhereUrl.parse(text)) { |
| 320 | + is DecodeResult.Ok -> parsed.value |
| 321 | + is DecodeResult.Invalid -> throw AssertionError(parsed.reason.detail) |
| 322 | + } |
| 323 | + } |
| 324 | + |
| 325 | + private fun key() = |
| 326 | + ( |
| 327 | + eu.nodepass.somewhere.protocol.auth.SharedKey |
| 328 | + .of(E2eEnvironment.sharedKey) as DecodeResult.Ok |
| 329 | + ).value |
| 330 | + |
| 331 | + private fun echoTarget(): Target = (Target.ofIpv4(byteArrayOf(127, 0, 0, 1), ORIGIN_PORT) as DecodeResult.Ok).value |
| 332 | + |
| 333 | + private companion object { |
| 334 | + const val ORIGIN_PORT = 28091 |
| 335 | + |
| 336 | + /** |
| 337 | + * The origin answers HTTP/1.1 whatever the request says, so this is |
| 338 | + * the reply's own version rather than the one asked for. |
| 339 | + */ |
| 340 | + const val ECHO = "HTTP/1.1 200" |
| 341 | + |
| 342 | + /** Past the Portal's `NOW_FLOW_PAIR_TIMEOUT=2s`, with room to spare. */ |
| 343 | + const val PAIR_WAIT = 4_000L |
| 344 | + |
| 345 | + /** Long enough to mean silence, short enough not to stall a suite. */ |
| 346 | + const val UPLINK_SILENCE_MILLIS = 2_000 |
| 347 | + } |
| 348 | +} |
0 commit comments