Reach the server by address when its name will not resolve
A measurement tool that cannot report from a broken network is useless exactly when it matters, and a wedged resolver is one of the faults this app is built to find — it should not also be the thing that stops the finding being delivered. The profile already carries the server's addresses; they are now kept and used when the name fails. Safe because the pin is the trust and the name is not part of it: the server presents the same certificate however it was reached, and a wrong address fails the pin like anything else. Only the primaries are cached — the alternate pair exists for NAT behaviour discovery and does not carry the control plane, so falling back to one would fail for a second, unrelated reason. Substituted only when the name genuinely does not resolve, and only after checking the candidate answers on the port: on a v4-only network a v6 address would otherwise be chosen and fail slowly, which is the wrong answer delivered late. The server had to meet it halfway. Sharing 443 by SNI meant a client arriving by IP sent no server name and got the Let's Encrypt certificate, failing the pin. A numeric host — or no SNI at all — now selects the pinned certificate and routes to the control plane. That is sound because the admin UI is only ever reached by name: browsers always send SNI, and nobody bookmarks an IP for a site with a CA-issued certificate. Verified against fmr: by IP on both families the served pin is the control one and /v1/profile answers 401, while fmr.echo-lot.app still serves the Let's Encrypt certificate and the admin UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d65dbbc75a
commit
2ed4d1f478
@@ -35,13 +35,57 @@ class ControlClient(
|
||||
private val controlUrl: String,
|
||||
pins: Set<String>,
|
||||
private val appVersion: String = "",
|
||||
/**
|
||||
* Addresses to fall back to when the server's name will not resolve, learned from its profile.
|
||||
*
|
||||
* A measurement tool that cannot report from a broken network is useless exactly when it
|
||||
* matters, and a wedged resolver is one of the faults it is built to find — it should not also
|
||||
* be the thing that stops the finding being delivered.
|
||||
*
|
||||
* Safe because the pin is the trust and the name is not part of it: the server presents the
|
||||
* same certificate whether it was reached by name or by address, and a wrong address fails the
|
||||
* pin like anything else would.
|
||||
*/
|
||||
private val fallbackAddrs: List<String> = emptyList(),
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val socketFactory = Pinning.sslContext(pins).socketFactory
|
||||
|
||||
/**
|
||||
* The base URL to use, substituting a cached address only when the name genuinely fails.
|
||||
*
|
||||
* Resolved once per client and only on failure, so a working network pays nothing and never
|
||||
* silently drifts onto an address that may be stale.
|
||||
*/
|
||||
private val base: String by lazy { resolveBase() }
|
||||
|
||||
private fun resolveBase(): String {
|
||||
if (fallbackAddrs.isEmpty()) return controlUrl
|
||||
val uri = runCatching { java.net.URI(controlUrl) }.getOrNull() ?: return controlUrl
|
||||
val host = uri.host ?: return controlUrl
|
||||
if (runCatching { java.net.InetAddress.getByName(host) }.isSuccess) return controlUrl
|
||||
|
||||
val port = if (uri.port > 0) uri.port else 443
|
||||
for (ip in fallbackAddrs) {
|
||||
// Checked rather than assumed: on a v4-only network a v6 address would otherwise be
|
||||
// chosen and fail slowly, which is the wrong answer delivered late.
|
||||
val reachable = runCatching {
|
||||
java.net.Socket().use { sock ->
|
||||
sock.connect(java.net.InetSocketAddress(ip, port), 4000)
|
||||
true
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
if (reachable) {
|
||||
val literal = if (ip.contains(':')) "[$ip]" else ip
|
||||
return uri.scheme + "://" + literal + ":" + port
|
||||
}
|
||||
}
|
||||
return controlUrl
|
||||
}
|
||||
|
||||
private fun open(path: String, method: String, credential: String?): HttpsURLConnection {
|
||||
val conn = URL(controlUrl.trimEnd('/') + path).openConnection() as HttpsURLConnection
|
||||
val conn = URL(base.trimEnd('/') + path).openConnection() as HttpsURLConnection
|
||||
conn.sslSocketFactory = socketFactory
|
||||
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
|
||||
conn.requestMethod = method
|
||||
|
||||
Reference in New Issue
Block a user