diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Account.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Account.kt index 584c163..837ea8f 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Account.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Account.kt @@ -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(), + ) } diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index d2d26d3..2bbbfa2 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -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. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt index b1f7e9d..4c3b624 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt @@ -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 = + 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" diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt index 36a67a7..083eb62 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt @@ -35,13 +35,57 @@ class ControlClient( private val controlUrl: String, pins: Set, 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 = 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 diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 727bba2..3350c97 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -416,8 +416,30 @@ func serve(cfg *config.Config) error { // port number. ctlHandler := ctl.Handler() 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) { - 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 } if adminTLS != nil && adminTLS.GetCertificate != nil { @@ -426,11 +448,7 @@ func serve(cfg *config.Config) error { return &sharedCert, nil } route := func(w http.ResponseWriter, r *http.Request) { - host := r.Host - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - if cfg.ControlHostname != "" && strings.EqualFold(host, cfg.ControlHostname) { + if isControl(r.Host) { ctlHandler.ServeHTTP(w, r) return }