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:
mrambossek
2026-08-02 10:03:14 +02:00
co-authored by Claude Opus 5
parent d65dbbc75a
commit 2ed4d1f478
5 changed files with 98 additions and 9 deletions
@@ -118,6 +118,8 @@ class Account(private val settings: Settings) {
settings.accountName.takeIf { it.isNotBlank() } settings.accountName.takeIf { it.isNotBlank() }
}.getOrNull() }.getOrNull()
private fun client() = private fun client() = ControlClient(
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER) 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( private fun client() = ControlClient(
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER, 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 * 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. * 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. // Learned here so the next run's canary probe knows what to ask for.
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it } profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverFacts = describeFacts(profile) settings.serverFacts = describeFacts(profile)
rememberAddrs(profile)
val compat = Compat.check(profile, BuildConfig.APP_SEMVER) val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
val head = "${profile.name} · server ${profile.serverVersion} · " + val head = "${profile.name} · server ${profile.serverVersion} · " +
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}" "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 enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER) val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
settings.serverFacts = describeFacts(enrolled.profile) settings.serverFacts = describeFacts(enrolled.profile)
rememberAddrs(enrolled.profile)
enrolled.profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it } enrolled.profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverUrl = enrolled.controlUrl settings.serverUrl = enrolled.controlUrl
settings.serverPublicUrl = enrolled.publicUrl settings.serverPublicUrl = enrolled.publicUrl
@@ -188,6 +198,7 @@ class RunStore(context: Context, private val settings: Settings) {
val profile = client.profile(settings.serverCredential) val profile = client.profile(settings.serverCredential)
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it } profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
settings.serverFacts = describeFacts(profile) settings.serverFacts = describeFacts(profile)
rememberAddrs(profile)
// Compatibility before policy: an incompatible server may well advertise an upload // Compatibility before policy: an incompatible server may well advertise an upload
// policy it would never actually apply to us. // policy it would never actually apply to us.
@@ -119,6 +119,19 @@ class Settings(context: Context) {
get() = prefs.getString(SERVER_FACTS, "") ?: "" get() = prefs.getString(SERVER_FACTS, "") ?: ""
set(v) = prefs.edit().putString(SERVER_FACTS, v).apply() 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 var serverCredential: String
get() = prefs.getString(SERVER_CRED, "") ?: "" get() = prefs.getString(SERVER_CRED, "") ?: ""
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply() 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_CRED = "server_credential"
const val SERVER_PUBLIC_URL = "server_public_url" const val SERVER_PUBLIC_URL = "server_public_url"
const val SERVER_FACTS = "server_facts" const val SERVER_FACTS = "server_facts"
const val SERVER_ADDRS = "server_addrs"
const val CANARY_ZONE = "server_canary_zone" const val CANARY_ZONE = "server_canary_zone"
const val PENDING_VERIFIER = "pending_auth_verifier" const val PENDING_VERIFIER = "pending_auth_verifier"
const val PENDING_STATE = "pending_auth_state" const val PENDING_STATE = "pending_auth_state"
@@ -35,13 +35,57 @@ class ControlClient(
private val controlUrl: String, private val controlUrl: String,
pins: Set<String>, pins: Set<String>,
private val appVersion: 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 json = Json { ignoreUnknownKeys = true }
private val socketFactory = Pinning.sslContext(pins).socketFactory 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 { 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.sslSocketFactory = socketFactory
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
conn.requestMethod = method conn.requestMethod = method
+24 -6
View File
@@ -416,8 +416,30 @@ func serve(cfg *config.Config) error {
// port number. // port number.
ctlHandler := ctl.Handler() ctlHandler := ctl.Handler()
sharedCert := cert sharedCert := cert
// Which side of the port a request belongs to.
//
// The control hostname is the obvious case. A bare IP is the other one, and it matters: a
// client whose DNS has failed can still reach the server by an address it cached from the
// profile, and a measurement tool that cannot report from a broken network is useless
// precisely when it is needed. That client authenticates by pin, so the name it used to get
// here is not part of the trust decision.
//
// Safe to route that way because the admin UI is only ever reached by name: browsers always
// send SNI and nobody bookmarks an IP for a site with a Let's Encrypt certificate. Anything
// addressing this server numerically is a pinned client.
isControl := func(host string) bool {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if cfg.ControlHostname != "" && strings.EqualFold(host, cfg.ControlHostname) {
return true
}
return net.ParseIP(host) != nil
}
pickCert := func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { pickCert := func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if cfg.ControlHostname != "" && strings.EqualFold(hi.ServerName, cfg.ControlHostname) { // No SNI at all also means a numeric client: every browser sends it.
if hi.ServerName == "" || isControl(hi.ServerName) {
return &sharedCert, nil return &sharedCert, nil
} }
if adminTLS != nil && adminTLS.GetCertificate != nil { if adminTLS != nil && adminTLS.GetCertificate != nil {
@@ -426,11 +448,7 @@ func serve(cfg *config.Config) error {
return &sharedCert, nil return &sharedCert, nil
} }
route := func(w http.ResponseWriter, r *http.Request) { route := func(w http.ResponseWriter, r *http.Request) {
host := r.Host if isControl(r.Host) {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if cfg.ControlHostname != "" && strings.EqualFold(host, cfg.ControlHostname) {
ctlHandler.ServeHTTP(w, r) ctlHandler.ServeHTTP(w, r)
return return
} }