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
@@ -118,6 +118,8 @@ class Account(private val settings: Settings) {
|
||||
settings.accountName.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun client() =
|
||||
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
|
||||
private fun client() = ControlClient(
|
||||
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||
fallbackAddrs = settings.serverAddrList(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,8 +80,16 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
|
||||
private fun client() = ControlClient(
|
||||
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||
fallbackAddrs = settings.serverAddrList(),
|
||||
)
|
||||
|
||||
/** Remembers where the server lives, so a later run can reach it without DNS. */
|
||||
private fun rememberAddrs(p: app.echo_lot.protocol.Profile) {
|
||||
val addrs = p.targets.flatMap { listOfNotNull(it.ip4, it.ip6) }
|
||||
.filter { it.isNotBlank() }
|
||||
if (addrs.isNotEmpty()) settings.serverAddrs = addrs.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the configured server without uploading anything: reachable, pinned, compatible, and
|
||||
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
|
||||
@@ -93,6 +101,7 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
// Learned here so the next run's canary probe knows what to ask for.
|
||||
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||
settings.serverFacts = describeFacts(profile)
|
||||
rememberAddrs(profile)
|
||||
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||
@@ -156,6 +165,7 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
||||
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
|
||||
settings.serverFacts = describeFacts(enrolled.profile)
|
||||
rememberAddrs(enrolled.profile)
|
||||
enrolled.profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||
settings.serverUrl = enrolled.controlUrl
|
||||
settings.serverPublicUrl = enrolled.publicUrl
|
||||
@@ -188,6 +198,7 @@ class RunStore(context: Context, private val settings: Settings) {
|
||||
val profile = client.profile(settings.serverCredential)
|
||||
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||
settings.serverFacts = describeFacts(profile)
|
||||
rememberAddrs(profile)
|
||||
|
||||
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||
// policy it would never actually apply to us.
|
||||
|
||||
@@ -119,6 +119,19 @@ class Settings(context: Context) {
|
||||
get() = prefs.getString(SERVER_FACTS, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_FACTS, v).apply()
|
||||
|
||||
/**
|
||||
* The server's own addresses, learned from its profile, for reaching it when DNS will not.
|
||||
*
|
||||
* Only the primaries: 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.
|
||||
*/
|
||||
var serverAddrs: String
|
||||
get() = prefs.getString(SERVER_ADDRS, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_ADDRS, v).apply()
|
||||
|
||||
fun serverAddrList(): List<String> =
|
||||
serverAddrs.split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
var serverCredential: String
|
||||
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
||||
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
||||
@@ -196,6 +209,7 @@ class Settings(context: Context) {
|
||||
const val SERVER_CRED = "server_credential"
|
||||
const val SERVER_PUBLIC_URL = "server_public_url"
|
||||
const val SERVER_FACTS = "server_facts"
|
||||
const val SERVER_ADDRS = "server_addrs"
|
||||
const val CANARY_ZONE = "server_canary_zone"
|
||||
const val PENDING_VERIFIER = "pending_auth_verifier"
|
||||
const val PENDING_STATE = "pending_auth_state"
|
||||
|
||||
@@ -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