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() }
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user