A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
11f0fe9fee
commit
6cd7a0d5dc
@@ -0,0 +1,58 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import okhttp3.ConnectionPool
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Which bridge, if any, the app's HTTP clients route through.
|
||||
*
|
||||
* 🔴 **A holder rather than a constructor argument, because there are ten
|
||||
* construction sites and no way to be sure of catching them all.** `RelayClient`,
|
||||
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
|
||||
* `RelayDiscovery` and the background service each build their own client, some
|
||||
* from default parameters evaluated at field-initialisation time. Threading a
|
||||
* bridge through every one would work until somebody adds an eleventh — and the
|
||||
* eleventh would connect directly, successfully, with nothing to show that it
|
||||
* had. A missed site here does not fail; it silently does not protect.
|
||||
*
|
||||
* ⚠️ **The socket factory is installed whether or not a bridge is set.** A
|
||||
* client is built once and the setting changes later, so the factory has to be
|
||||
* able to follow it. With no bridge it delegates to the platform's own factory,
|
||||
* which is exactly what OkHttp would have used.
|
||||
*/
|
||||
object Obfs4Routing {
|
||||
|
||||
@Volatile
|
||||
private var current: Obfs4Bridge? = null
|
||||
|
||||
/**
|
||||
* One pool for every default client.
|
||||
*
|
||||
* 🔴 **Shared so it can be emptied.** OkHttp keeps connections alive across
|
||||
* requests; a connection opened before the bridge changed still runs over
|
||||
* the old transport, and would go on serving requests for minutes after the
|
||||
* user switched bridges — or after they turned one OFF, which is the
|
||||
* dangerous direction. Eviction on change is the only thing that stops that,
|
||||
* and it needs a pool we hold.
|
||||
*
|
||||
* ⚠️ The idle window is short for the same reason a bridge that goes away
|
||||
* must not leave requests queued behind a dead tunnel.
|
||||
*/
|
||||
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
|
||||
|
||||
/** The bridge in force right now, consulted per socket. */
|
||||
val bridge: Obfs4Bridge? get() = current
|
||||
|
||||
/**
|
||||
* Route through [bridge] from now on, or directly when it is null.
|
||||
*
|
||||
* Existing pooled connections are dropped, because they are already
|
||||
* committed to the previous transport.
|
||||
*/
|
||||
fun use(bridge: Obfs4Bridge?) {
|
||||
if (bridge == current) return
|
||||
current = bridge
|
||||
pool.evictAll()
|
||||
}
|
||||
}
|
||||
@@ -86,17 +86,22 @@ class RelayClient(
|
||||
// sealed blobs and envelope traffic surviving on disk after the user
|
||||
// deletes a chat is exactly what "deleted means deleted" forbids.
|
||||
.cache(null)
|
||||
.apply {
|
||||
if (bridge != null) {
|
||||
socketFactory(Obfs4SocketFactory(bridge))
|
||||
// ⚠️ **Connections must not be pooled across a transport
|
||||
// change**, and the handshake makes each one expensive to
|
||||
// establish, so the pool is kept but the idle window is
|
||||
// short: a bridge that goes away should not leave requests
|
||||
// queued behind a dead tunnel for minutes.
|
||||
connectionPool(okhttp3.ConnectionPool(4, 2, TimeUnit.MINUTES))
|
||||
}
|
||||
}
|
||||
// 🔴 **Installed unconditionally, and that is the point.** The
|
||||
// parameter above is for tests and interop, which pin one
|
||||
// bridge. Everything the app builds passes nothing — so if the
|
||||
// factory were only installed when a bridge was already known,
|
||||
// the setting could never reach the ten sites that construct
|
||||
// clients, several of them from default parameters evaluated
|
||||
// before the user has touched anything. With no bridge in force
|
||||
// the factory delegates to the platform's own, so this changes
|
||||
// nothing for the overwhelming majority who never set one.
|
||||
.socketFactory(Obfs4SocketFactory { bridge ?: Obfs4Routing.bridge })
|
||||
// ⚠️ **Connections must not outlive a transport change**, and an
|
||||
// obfs4 handshake makes each one expensive to establish, so the
|
||||
// pool is shared — `Obfs4Routing.use` empties it — and its idle
|
||||
// window is short: a bridge that goes away should not leave
|
||||
// requests queued behind a dead tunnel for minutes.
|
||||
.connectionPool(Obfs4Routing.pool)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.obfs4.Obfs4Bridge
|
||||
import io.flamenet.obfs4.Obfs4Socket
|
||||
import io.flamenet.obfs4.Obfs4SocketFactory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The seam that carries the setting to the ten places that build HTTP clients.
|
||||
*
|
||||
* 🔴 **The bug this is for: obfs4 was reachable from nothing.** Every client in
|
||||
* the app took `defaultHttpClient()`'s null default, so the transport that had
|
||||
* been built, tested and proven against lyrebird was not on any code path a
|
||||
* user could reach. Threading a bridge through each construction site would
|
||||
* have worked until the eleventh was added — and the eleventh would connect
|
||||
* directly, successfully, with nothing to show for it.
|
||||
*/
|
||||
class Obfs4RoutingTest {
|
||||
|
||||
private fun bridge(host: String = "198.51.100.7", port: Int = 4444) =
|
||||
Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 })
|
||||
|
||||
@AfterTest
|
||||
fun clearRouting() = Obfs4Routing.use(null)
|
||||
|
||||
@Test
|
||||
fun `no bridge means no bridge`() {
|
||||
Obfs4Routing.use(null)
|
||||
assertNull(Obfs4Routing.bridge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bridge in force is the last one set`() {
|
||||
Obfs4Routing.use(bridge(host = "198.51.100.7"))
|
||||
assertEquals("198.51.100.7", Obfs4Routing.bridge?.host)
|
||||
Obfs4Routing.use(bridge(host = "203.0.113.9", port = 9001))
|
||||
assertEquals("203.0.113.9", Obfs4Routing.bridge?.host)
|
||||
assertEquals(9001, Obfs4Routing.bridge?.port)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The factory is installed whether or not a bridge is set, so the
|
||||
* no-bridge path has to be an ordinary socket.** If it were an
|
||||
* `Obfs4Socket` pointed at nothing, installing the factory would break the
|
||||
* app for the overwhelming majority who never configure a bridge — and it
|
||||
* would break it at connect time, on the network, not at build time.
|
||||
*/
|
||||
@Test
|
||||
fun `with no bridge the factory hands out a plain socket`() {
|
||||
Obfs4Routing.use(null)
|
||||
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
|
||||
val socket = factory.createSocket()
|
||||
assertFalse(socket is Obfs4Socket, "a plain connection must not go through obfs4")
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/** And with one set, the same factory hands out the transport. */
|
||||
@Test
|
||||
fun `with a bridge the same factory hands out an obfs4 socket`() {
|
||||
Obfs4Routing.use(bridge())
|
||||
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
|
||||
val socket = factory.createSocket()
|
||||
assertTrue(socket is Obfs4Socket, "a configured bridge must be used")
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The factory reads the setting per socket, not once at construction.**
|
||||
* A client is built long before the user opens settings; a factory that
|
||||
* captured the bridge at build time would keep using whatever was in force
|
||||
* then — including nothing at all — for the life of the process.
|
||||
*/
|
||||
@Test
|
||||
fun `a factory built before the bridge was set still picks it up`() {
|
||||
Obfs4Routing.use(null)
|
||||
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
|
||||
factory.createSocket().use {
|
||||
assertFalse(it is Obfs4Socket)
|
||||
}
|
||||
Obfs4Routing.use(bridge())
|
||||
factory.createSocket().use {
|
||||
assertTrue(it is Obfs4Socket, "the factory did not follow the setting")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The default client must carry the routing factory, or none of the
|
||||
* above reaches anything the app actually uses.
|
||||
*/
|
||||
@Test
|
||||
fun `the default http client routes through the holder`() {
|
||||
Obfs4Routing.use(bridge())
|
||||
val client = RelayClient.defaultHttpClient()
|
||||
val socket = client.socketFactory.createSocket()
|
||||
assertTrue(socket is Obfs4Socket,
|
||||
"the app's default client ignored the configured bridge")
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/** And with nothing configured it is a plain connection, as it always was. */
|
||||
@Test
|
||||
fun `the default http client connects directly when nothing is configured`() {
|
||||
Obfs4Routing.use(null)
|
||||
val socket = RelayClient.defaultHttpClient().socketFactory.createSocket()
|
||||
assertFalse(socket is Obfs4Socket)
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Pooled connections outlive the setting that created them.** OkHttp
|
||||
* keeps a connection alive across requests, so one opened through the old
|
||||
* bridge would go on serving requests for minutes after the user switched —
|
||||
* or after they turned a bridge OFF, which is the dangerous direction.
|
||||
*/
|
||||
@Test
|
||||
fun `changing the bridge empties the connection pool`() {
|
||||
Obfs4Routing.use(bridge(host = "198.51.100.7"))
|
||||
Obfs4Routing.use(bridge(host = "203.0.113.9"))
|
||||
assertEquals(0, Obfs4Routing.pool.connectionCount(),
|
||||
"connections committed to the previous transport were kept")
|
||||
}
|
||||
|
||||
/** ⚠️ Setting the same bridge twice must not tear down live connections. */
|
||||
@Test
|
||||
fun `setting the same bridge again is not a change`() {
|
||||
val one = bridge()
|
||||
Obfs4Routing.use(one)
|
||||
val before = Obfs4Routing.bridge
|
||||
Obfs4Routing.use(bridge())
|
||||
assertEquals(before, Obfs4Routing.bridge)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user