From 91f8eda94ac1fad9ed1efd705f99a7c54762d670 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Mon, 11 May 2026 17:04:04 +0330 Subject: [PATCH] Implemented VPN/DNS resilience --- app/build.gradle.kts | 1 + .../MonitorController.kt | 7 + .../data/MonitorDatabase.kt | 98 +++++- .../data/MonitorHttpClient.kt | 297 +++++++++++++++--- .../data/MonitorRepository.kt | 25 +- .../domain/RouteAttemptPlanner.kt | 19 ++ .../domain/UrlRules.kt | 13 + .../model/MonitorModels.kt | 47 +++ .../ngxhttpmonitoringclient/ui/MonitorApp.kt | 58 +++- .../domain/RouteAttemptPlannerTest.kt | 36 +++ .../domain/UrlRulesTest.kt | 8 + gradle/libs.versions.toml | 2 + 12 files changed, 557 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlanner.kt create mode 100644 app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlannerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50129a1..52cd3c5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.kotlinx.coroutines.android) + implementation(libs.okhttp) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt index 152c061..679e1e2 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt @@ -41,12 +41,14 @@ class MonitorController(context: Context) { val summaries = repository.latestSummaries(servers.map { it.id }) val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty() val alerts = repository.alerts() + val routeDiagnostics = repository.routeDiagnostics() _state.update { it.copy( servers = servers, selectedServerId = selected, summaries = summaries, history = history, + routeDiagnostics = routeDiagnostics, alerts = alerts, globalMessage = null, ) @@ -140,10 +142,12 @@ class MonitorController(context: Context) { val servers = repository.servers() val summaries = repository.latestSummaries(servers.map { it.id }) val history = repository.history(server.id) + val routeDiagnostics = repository.routeDiagnostics() _state.update { it.copy( servers = servers, summaries = summaries, + routeDiagnostics = routeDiagnostics, selectedServerId = server.id, showDetail = true, selectedTab = DetailTab.Overview, @@ -191,6 +195,7 @@ class MonitorController(context: Context) { selectedTab = DetailTab.Overview, summaries = repository.latestSummaries(servers.map { item -> item.id }), history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(), + routeDiagnostics = repository.routeDiagnostics(), alerts = repository.alerts(), globalMessage = "Server deleted", ) @@ -233,10 +238,12 @@ class MonitorController(context: Context) { val selected = _state.value.selectedServerId val history = selected?.let { repository.history(it) } val alerts = repository.alerts() + val routeDiagnostics = repository.routeDiagnostics() withContext(Dispatchers.Main.immediate) { _state.update { it.copy( alerts = alerts, + routeDiagnostics = routeDiagnostics, history = if (selected != null && history != null) it.history + (selected to history) else it.history, ) } diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt index 6104db8..27def21 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt @@ -10,12 +10,16 @@ import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary +import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue +import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics +import net.rodakot.ngxhttpmonitoringclient.model.RouteKind +import net.rodakot.ngxhttpmonitoringclient.model.RoutePolicy import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus import org.json.JSONArray import java.util.concurrent.TimeUnit -class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) { +class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 2) { override fun onCreate(db: SQLiteDatabase) { db.execSQL( """ @@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor id TEXT PRIMARY KEY, name TEXT NOT NULL, base_url TEXT NOT NULL, + fallback_ips_json TEXT NOT NULL, + route_policy TEXT NOT NULL, tags_json TEXT NOT NULL, favorite INTEGER NOT NULL, allow_http INTEGER NOT NULL, @@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor """.trimIndent(), ) db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)") + createRouteDiagnosticsTable(db) } - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) { + db.execSQL("ALTER TABLE servers ADD COLUMN fallback_ips_json TEXT NOT NULL DEFAULT '[]'") + db.execSQL("ALTER TABLE servers ADD COLUMN route_policy TEXT NOT NULL DEFAULT '${RoutePolicy.AutoFallback.name}'") + createRouteDiagnosticsTable(db) + } + } @Synchronized fun listServers(): List { @@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor writableDatabase.delete("servers", "id = ?", arrayOf(serverId)) writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId)) writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId)) + writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId)) } @Synchronized @@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor return result } + @Synchronized + fun upsertRouteDiagnostics(diagnostics: RouteDiagnostics) { + writableDatabase.insertWithOnConflict( + "route_diagnostics", + null, + diagnostics.toValues(), + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + + @Synchronized + fun routeDiagnostics(): Map { + val result = linkedMapOf() + readableDatabase.query("route_diagnostics", null, null, null, null, null, "timestamp_millis DESC").use { cursor -> + while (cursor.moveToNext()) { + val diagnostics = cursor.toRouteDiagnostics() + result[diagnostics.serverId] = diagnostics + } + } + return result + } + @Synchronized fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) { val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong()) @@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor put("id", id) put("name", name) put("base_url", baseUrl) + put("fallback_ips_json", listToJson(fallbackIpAddresses)) + put("route_policy", routePolicy.name) put("tags_json", tagsToJson(tags)) put("favorite", favorite.asInt()) put("allow_http", allowHttp.asInt()) @@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor put("resolved", resolved.asInt()) } + private fun RouteDiagnostics.toValues() = ContentValues().apply { + put("server_id", serverId) + put("timestamp_millis", timestampMillis) + put("active_network", activeNetwork) + put("route_used", routeUsed.name) + put("dns_result", dnsResult) + put("issue", issue.name) + put("vpn_active", vpnActive.asInt()) + put("fallback_used", fallbackUsed.asInt()) + put("success", success.asInt()) + } + private fun Cursor.toServer(): ServerProfile { return ServerProfile( id = string("id"), name = string("name"), baseUrl = string("base_url"), + fallbackIpAddresses = listFromJson(string("fallback_ips_json")), + routePolicy = runCatching { RoutePolicy.valueOf(string("route_policy")) }.getOrDefault(RoutePolicy.AutoFallback), tags = tagsFromJson(string("tags_json")), favorite = int("favorite") == 1, allowHttp = int("allow_http") == 1, @@ -294,17 +346,55 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor ) } + private fun Cursor.toRouteDiagnostics(): RouteDiagnostics { + return RouteDiagnostics( + serverId = string("server_id"), + timestampMillis = long("timestamp_millis"), + activeNetwork = string("active_network"), + routeUsed = runCatching { RouteKind.valueOf(string("route_used")) }.getOrDefault(RouteKind.SystemDns), + dnsResult = string("dns_result"), + issue = runCatching { NetworkIssue.valueOf(string("issue")) }.getOrDefault(NetworkIssue.Unknown), + vpnActive = int("vpn_active") == 1, + fallbackUsed = int("fallback_used") == 1, + success = int("success") == 1, + ) + } + private fun tagsToJson(tags: List): String { + return listToJson(tags) + } + + private fun listToJson(values: List): String { val array = JSONArray() - tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put) + values.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put) return array.toString() } - private fun tagsFromJson(value: String): List = runCatching { + private fun tagsFromJson(value: String): List = listFromJson(value) + + private fun listFromJson(value: String): List = runCatching { val array = JSONArray(value) List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() } }.getOrDefault(emptyList()) + private fun createRouteDiagnosticsTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS route_diagnostics ( + server_id TEXT PRIMARY KEY, + timestamp_millis INTEGER NOT NULL, + active_network TEXT NOT NULL, + route_used TEXT NOT NULL, + dns_result TEXT NOT NULL, + issue TEXT NOT NULL, + vpn_active INTEGER NOT NULL, + fallback_used INTEGER NOT NULL, + success INTEGER NOT NULL + ) + """.trimIndent(), + ) + } + private fun Boolean.asInt() = if (this) 1 else 0 private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value) diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt index 0522a57..313e35a 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt @@ -1,35 +1,70 @@ package net.rodakot.ngxhttpmonitoringclient.data -import android.util.Base64 +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import net.rodakot.ngxhttpmonitoringclient.domain.RouteAttemptPlanner import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig +import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue +import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics +import net.rodakot.ngxhttpmonitoringclient.model.RouteKind import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus +import okhttp3.Credentials +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response import java.io.BufferedReader +import java.io.InterruptedIOException import java.io.IOException import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL +import java.net.ConnectException +import java.net.InetAddress +import java.net.NoRouteToHostException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException -class MonitorHttpClient { - fun fetchApi(server: ServerProfile, auth: AuthConfig): String { - return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth) +class MonitorHttpClient(context: Context) { + private val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + private val baseClient = OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(20, TimeUnit.SECONDS) + .build() + + fun fetchApi(server: ServerProfile, auth: AuthConfig): RouteResponse { + return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/api")) } - fun fetchHealth(server: ServerProfile, auth: AuthConfig): String { - return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth) + fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse { + return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health")) } - fun streamLive(server: ServerProfile, auth: AuthConfig, onMetrics: (String) -> Unit) { - val connection = openConnection(UrlRules.endpoint(server.baseUrl, "/monitor/live"), auth).apply { - readTimeout = 0 - setRequestProperty("Accept", "text/event-stream") - } - try { - val status = connection.responseCode - if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus()) - BufferedReader(InputStreamReader(connection.inputStream, StandardCharsets.UTF_8)).use { reader -> + fun streamLive( + server: ServerProfile, + auth: AuthConfig, + onDiagnostics: (RouteDiagnostics) -> Unit, + onMetrics: (String) -> Unit, + ) { + val routed = openRoutedResponse( + server = server, + auth = auth, + url = UrlRules.endpoint(server.baseUrl, "/monitor/live"), + accept = "text/event-stream", + streaming = true, + ) + onDiagnostics(routed.diagnostics) + routed.response.use { response -> + if (!response.isSuccessful) { + throw response.toException(routed.diagnostics) + } + BufferedReader(InputStreamReader(response.body!!.byteStream(), StandardCharsets.UTF_8)).use { reader -> var eventName = "message" val data = StringBuilder() while (!Thread.currentThread().isInterrupted) { @@ -47,41 +82,172 @@ class MonitorHttpClient { } } } - } finally { - connection.disconnect() } } - private fun fetchText(url: String, auth: AuthConfig): String { - val connection = openConnection(url, auth) - return try { - val status = connection.responseCode - if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus()) - connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } - } catch (exception: IOException) { - throw MonitorClientException(null, ServerStatus.Offline, exception.message ?: "Network error", exception) - } finally { - connection.disconnect() + private fun fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse { + val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false) + routed.response.use { response -> + if (!response.isSuccessful) { + throw response.toException(routed.diagnostics) + } + val body = response.body?.string().orEmpty() + return RouteResponse(body, routed.diagnostics) } } - private fun openConnection(url: String, auth: AuthConfig): HttpURLConnection { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connectTimeout = 8_000 - connection.readTimeout = 15_000 - connection.requestMethod = "GET" - connection.setRequestProperty("Accept", "application/json") + private fun openRoutedResponse( + server: ServerProfile, + auth: AuthConfig, + url: String, + accept: String, + streaming: Boolean, + ): RoutedResponse { + val state = networkState() + val attempts = RouteAttemptPlanner.attempts( + hasFallbackAliases = server.fallbackIpAddresses.isNotEmpty(), + vpnActive = state.vpnActive, + lanAvailable = state.lanNetwork != null, + ) + var lastDiagnostics: RouteDiagnostics? = null + var lastException: IOException? = null + + attempts.forEach { kind -> + val diagnostics = diagnosticsFor(server, kind, state) + try { + val response = clientFor(kind, state, server, streaming) + .newCall(request(url, auth, accept)) + .execute() + return RoutedResponse(response, diagnostics.copy(success = true, issue = NetworkIssue.None)) + } catch (exception: IOException) { + lastDiagnostics = diagnostics.copy(success = false, issue = classify(exception, kind)) + lastException = exception + } + } + + val diagnostics = lastDiagnostics ?: RouteDiagnostics( + serverId = server.id, + timestampMillis = System.currentTimeMillis(), + activeNetwork = state.activeNetworkLabel, + vpnActive = state.vpnActive, + issue = NetworkIssue.Unknown, + ) + throw MonitorClientException( + httpStatus = null, + monitorStatus = ServerStatus.Offline, + message = diagnostics.issue.label, + diagnostics = diagnostics, + cause = lastException, + ) + } + + private fun clientFor( + kind: RouteKind, + state: NetworkState, + server: ServerProfile, + streaming: Boolean, + ): OkHttpClient { + val builder = baseClient.newBuilder() + .readTimeout(if (streaming) 0 else 15, TimeUnit.SECONDS) + .callTimeout(if (streaming) 0 else 20, TimeUnit.SECONDS) + + val lanNetwork = state.lanNetwork + val dnsDelegate = when { + kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns -> lanNetwork?.let(::NetworkDns) ?: Dns.SYSTEM + else -> Dns.SYSTEM + } + if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) { + lanNetwork?.let { builder.socketFactory(it.socketFactory) } + } + if (kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns) { + builder.dns(FallbackDns(server.hostName(), server.fallbackIpAddresses, dnsDelegate)) + } else { + builder.dns(dnsDelegate) + } + return builder.build() + } + + private fun request(url: String, auth: AuthConfig, accept: String): Request { + val builder = Request.Builder() + .url(url) + .get() + .header("Accept", accept) auth.token?.takeIf { it.isNotBlank() }?.let { - connection.setRequestProperty("X-Monitor-Token", it) + builder.header("X-Monitor-Token", it) } if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) { - val encoded = Base64.encodeToString( - "${auth.basicUsername}:${auth.basicPassword}".toByteArray(StandardCharsets.UTF_8), - Base64.NO_WRAP, - ) - connection.setRequestProperty("Authorization", "Basic $encoded") + builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword)) } - return connection + return builder.build() + } + + private fun diagnosticsFor(server: ServerProfile, kind: RouteKind, state: NetworkState): RouteDiagnostics { + val fallbackUsed = kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns + val dnsResult = when (kind) { + RouteKind.SystemDns -> "System DNS" + RouteKind.FallbackDns -> server.fallbackIpAddresses.joinToString(", ") + RouteKind.LanSystemDns -> "LAN DNS" + RouteKind.LanFallbackDns -> server.fallbackIpAddresses.joinToString(", ") + } + return RouteDiagnostics( + serverId = server.id, + timestampMillis = System.currentTimeMillis(), + activeNetwork = state.activeNetworkLabel, + routeUsed = kind, + dnsResult = dnsResult.ifBlank { "None" }, + issue = if (state.vpnActive) NetworkIssue.VpnActive else NetworkIssue.None, + vpnActive = state.vpnActive, + fallbackUsed = fallbackUsed, + success = false, + ) + } + + private fun networkState(): NetworkState { + val networks = connectivityManager?.allNetworks.orEmpty() + val activeLabel = connectivityManager?.activeNetwork + ?.let { network -> connectivityManager.getNetworkCapabilities(network)?.transportLabel() } + ?: "No active network" + val vpnActive = networks.any { network -> + connectivityManager?.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + } + val lanNetwork = networks.firstOrNull { network -> + val capabilities = connectivityManager?.getNetworkCapabilities(network) + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true || + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true + } + return NetworkState(activeLabel, vpnActive, lanNetwork) + } + + private fun classify(exception: IOException, kind: RouteKind): NetworkIssue { + return when (exception) { + is UnknownHostException -> NetworkIssue.DnsFailure + is SocketTimeoutException -> NetworkIssue.Timeout + is SSLException -> NetworkIssue.TlsFailure + is NoRouteToHostException -> NetworkIssue.LanRouteBlocked + is ConnectException -> if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) { + NetworkIssue.LanRouteBlocked + } else { + NetworkIssue.Timeout + } + is InterruptedIOException -> NetworkIssue.Timeout + else -> NetworkIssue.Unknown + } + } + + private fun Response.toException(diagnostics: RouteDiagnostics): MonitorClientException { + val issue = code.toNetworkIssue() + return MonitorClientException( + httpStatus = code, + monitorStatus = code.toMonitorStatus(), + message = code.messageForStatus(), + diagnostics = diagnostics.copy(success = false, issue = issue), + ) + } + + private fun Int.toNetworkIssue(): NetworkIssue = when (this) { + 401 -> NetworkIssue.AuthFailure + 429 -> NetworkIssue.RateLimited + else -> NetworkIssue.ApiFailure } private fun Int.toMonitorStatus(): ServerStatus = when (this) { @@ -99,11 +265,58 @@ class MonitorHttpClient { 429 -> "Monitor rate limit was exceeded" else -> "HTTP $this from monitor endpoint" } + + private fun NetworkCapabilities.transportLabel(): String { + return buildList { + if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN") + if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi") + if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet") + if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("Cellular") + }.ifEmpty { listOf("Unknown") }.joinToString(" + ") + } + + private fun ServerProfile.hostName(): String = java.net.URI(baseUrl).host.orEmpty() +} + +data class RouteResponse( + val body: String, + val diagnostics: RouteDiagnostics, +) + +private data class RoutedResponse( + val response: Response, + val diagnostics: RouteDiagnostics, +) + +private data class NetworkState( + val activeNetworkLabel: String, + val vpnActive: Boolean, + val lanNetwork: Network?, +) + +private class FallbackDns( + private val hostName: String, + private val fallbackIps: List, + private val delegate: Dns, +) : Dns { + override fun lookup(hostname: String): List { + if (hostname.equals(hostName, ignoreCase = true) && fallbackIps.isNotEmpty()) { + return fallbackIps.map { InetAddress.getByName(it) } + } + return delegate.lookup(hostname) + } +} + +private class NetworkDns(private val network: Network) : Dns { + override fun lookup(hostname: String): List { + return network.getAllByName(hostname).toList() + } } class MonitorClientException( val httpStatus: Int?, val monitorStatus: ServerStatus, override val message: String, + val diagnostics: RouteDiagnostics, cause: Throwable? = null, ) : Exception(message, cause) diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt index 980f625..4332724 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt @@ -18,7 +18,7 @@ import org.json.JSONException class MonitorRepository(context: Context) { private val database = MonitorDatabase(context.applicationContext) private val cipher = CredentialCipher() - private val client = MonitorHttpClient() + private val client = MonitorHttpClient(context.applicationContext) private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30) fun servers(): List = database.listServers() @@ -27,6 +27,8 @@ class MonitorRepository(context: Context) { fun history(serverId: String): List = database.samplesForServer(serverId) + fun routeDiagnostics(): Map = database.routeDiagnostics() + fun alerts(): List = database.alerts() fun draftFor(server: ServerProfile?): ServerEditorDraft { @@ -35,6 +37,7 @@ class MonitorRepository(context: Context) { id = server.id, name = server.name, baseUrl = server.baseUrl, + fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "), tags = server.tags.joinToString(", "), favorite = server.favorite, allowHttp = server.allowHttp, @@ -63,6 +66,7 @@ class MonitorRepository(context: Context) { id = draft.id ?: UUID.randomUUID().toString(), name = draft.name.trim().ifBlank { baseUrl }, baseUrl = baseUrl, + fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses), tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(), favorite = draft.favorite, allowHttp = draft.allowHttp, @@ -91,8 +95,9 @@ class MonitorRepository(context: Context) { fun testConnection(draft: ServerEditorDraft): Result = runCatching { val server = buildServer(draft.copy(id = draft.id ?: "test")) - client.fetchHealth(server, authFor(server)) - "Connection works" + val response = client.fetchHealth(server, authFor(server)) + database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id)) + "Connection works through ${response.diagnostics.routeUsed.label}" } fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary { @@ -101,17 +106,19 @@ class MonitorRepository(context: Context) { fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult { return try { - val payload = client.fetchApi(server, authFor(server)) - val parsed = MonitorJsonParser.parse(server.id, payload) + val response = client.fetchApi(server, authFor(server)) + database.upsertRouteDiagnostics(response.diagnostics) + val parsed = MonitorJsonParser.parse(server.id, response.body) val events = AlertEvaluator.evaluate(server, parsed) val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded val summary = parsed.copy( status = status, message = if (status == ServerStatus.Online) "Live" else "Threshold breached", ) - persistSample(server, summary, payload, forcePersist) + persistSample(server, summary, response.body, forcePersist) RefreshResult(summary, recordAlerts(server, summary)) } catch (exception: MonitorClientException) { + database.upsertRouteDiagnostics(exception.diagnostics) val summary = MetricSummary( serverId = server.id, timestampMillis = System.currentTimeMillis(), @@ -142,7 +149,11 @@ class MonitorRepository(context: Context) { } fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) { - client.streamLive(server, authFor(server)) { payload -> + client.streamLive( + server = server, + auth = authFor(server), + onDiagnostics = database::upsertRouteDiagnostics, + ) { payload -> val parsed = MonitorJsonParser.parse(server.id, payload) val events = AlertEvaluator.evaluate(server, parsed) val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlanner.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlanner.kt new file mode 100644 index 0000000..32c0256 --- /dev/null +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlanner.kt @@ -0,0 +1,19 @@ +package net.rodakot.ngxhttpmonitoringclient.domain + +import net.rodakot.ngxhttpmonitoringclient.model.RouteKind + +object RouteAttemptPlanner { + fun attempts( + hasFallbackAliases: Boolean, + vpnActive: Boolean, + lanAvailable: Boolean, + ): List { + val result = mutableListOf(RouteKind.SystemDns) + if (hasFallbackAliases) result += RouteKind.FallbackDns + if (vpnActive && lanAvailable) { + result += RouteKind.LanSystemDns + if (hasFallbackAliases) result += RouteKind.LanFallbackDns + } + return result.distinct() + } +} diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt index ea9a6ef..a79ca06 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt @@ -1,6 +1,7 @@ package net.rodakot.ngxhttpmonitoringclient.domain import java.net.URI +import java.net.InetAddress object UrlRules { fun normalizeBaseUrl(input: String): String { @@ -30,4 +31,16 @@ object UrlRules { val normalizedPath = if (path.startsWith("/")) path else "/$path" return normalizeBaseUrl(baseUrl) + normalizedPath } + + fun parseFallbackIps(input: String): List { + return input + .split(',', '\n', ' ') + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .onEach { value -> + runCatching { InetAddress.getByName(value) } + .getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") } + } + } } diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt index be2f94c..eb945e6 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt @@ -13,6 +13,8 @@ data class ServerProfile( val id: String, val name: String, val baseUrl: String, + val fallbackIpAddresses: List = emptyList(), + val routePolicy: RoutePolicy = RoutePolicy.AutoFallback, val tags: List = emptyList(), val favorite: Boolean = false, val allowHttp: Boolean = false, @@ -30,6 +32,49 @@ data class AuthConfig( val basicPassword: String? = null, ) +enum class RoutePolicy { + AutoFallback, +} + +enum class RouteKind(val label: String) { + SystemDns("System DNS"), + FallbackDns("Fallback DNS"), + LanSystemDns("LAN DNS"), + LanFallbackDns("LAN fallback"), +} + +enum class NetworkIssue(val label: String) { + None("OK"), + DnsFailure("DNS failure"), + VpnActive("VPN active"), + LanRouteBlocked("LAN route blocked"), + Timeout("Timeout"), + TlsFailure("TLS failure"), + AuthFailure("Authentication failed"), + ApiFailure("API failure"), + RateLimited("Rate limited"), + Unknown("Network error"), +} + +data class RouteDiagnostics( + val serverId: String, + val timestampMillis: Long, + val activeNetwork: String = "Unknown", + val routeUsed: RouteKind = RouteKind.SystemDns, + val dnsResult: String = "System DNS", + val issue: NetworkIssue = NetworkIssue.None, + val vpnActive: Boolean = false, + val fallbackUsed: Boolean = false, + val success: Boolean = false, +) { + val summary: String + get() = if (success) { + "Connected through ${routeUsed.label}" + } else { + issue.label + } +} + data class AlertOverrides( val cpuPercent: Double? = null, val memoryPercent: Double? = null, @@ -87,6 +132,7 @@ data class ServerEditorDraft( val id: String? = null, val name: String = "", val baseUrl: String = "", + val fallbackIpAddresses: String = "", val tags: String = "", val favorite: Boolean = false, val allowHttp: Boolean = false, @@ -118,6 +164,7 @@ data class MonitorUiState( val servers: List = emptyList(), val summaries: Map = emptyMap(), val history: Map> = emptyMap(), + val routeDiagnostics: Map = emptyMap(), val alerts: List = emptyList(), val selectedServerId: String? = null, val showDetail: Boolean = false, diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt index a8f6e58..651f9eb 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt @@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold import net.rodakot.ngxhttpmonitoringclient.model.DetailTab import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState +import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue +import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus @@ -204,6 +206,7 @@ private fun FleetCommandDeck( ServerCommandRow( server = server, summary = state.summaries[server.id], + diagnostics = state.routeDiagnostics[server.id], onClick = { controller.selectServer(server.id) }, ) } @@ -346,7 +349,12 @@ private fun CommandFilters( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) { +private fun ServerCommandRow( + server: ServerProfile, + summary: MetricSummary?, + diagnostics: RouteDiagnostics?, + onClick: () -> Unit, +) { val status = summary?.status ?: ServerStatus.Unknown val health = healthScore(summary) Surface( @@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status)) if (!server.enabled) LabelCapsule("OFF", StatusUnknown) if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure) + RouteBadges(diagnostics) server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { @@ -423,6 +432,9 @@ private fun ServerCockpit( item { CockpitHero(server, summary, controller) } + item { + RoutePanel(state.routeDiagnostics[server.id]) + } item { CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab) } @@ -477,6 +489,25 @@ private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controll } } +@Composable +private fun RoutePanel(diagnostics: RouteDiagnostics?) { + DataPanel("Route") { + if (diagnostics == null) { + Text("No route check has completed yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RuleRow("Network", diagnostics.activeNetwork) + RuleRow("Route", diagnostics.routeUsed.label) + RuleRow("DNS", diagnostics.dnsResult) + RuleRow("VPN", if (diagnostics.vpnActive) "Active" else "Inactive") + RuleRow("Fallback", if (diagnostics.fallbackUsed) "Used" else "Not used") + RuleRow("Last result", diagnostics.summary) + RuleRow("Checked", diagnostics.timestampMillis.formatTime()) + } + } + } +} + @Composable private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) { Row( @@ -563,6 +594,7 @@ private fun SettingsCockpit(server: ServerProfile, controller: MonitorController RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%") RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms") RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}") + RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", ")) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") } OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") } @@ -665,6 +697,16 @@ private fun LabelCapsule(text: String, color: Color) { } } +@Composable +private fun RouteBadges(diagnostics: RouteDiagnostics?) { + if (diagnostics == null) return + if (diagnostics.vpnActive) LabelCapsule("VPN", StatusWarning) + if (diagnostics.fallbackUsed) LabelCapsule("LAN FALLBACK", StatusOnline) + if (!diagnostics.success && diagnostics.issue != NetworkIssue.None) { + LabelCapsule(diagnostics.issue.label.uppercase(), routeIssueColor(diagnostics.issue)) + } +} + @Composable private fun HealthBadge(score: Int, status: ServerStatus) { val color = colorForStatus(status) @@ -834,6 +876,7 @@ private fun ServerEditorDialog( } item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } } item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } } + item { EditorField("Fallback LAN IPs", draft.fallbackIpAddresses) { value -> onChange { it.copy(fallbackIpAddresses = value) } } } item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } } item { FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -1005,6 +1048,19 @@ private fun colorForPercent(value: Double?): Color = when { else -> StatusOnline } +private fun routeIssueColor(issue: NetworkIssue): Color = when (issue) { + NetworkIssue.None -> StatusOnline + NetworkIssue.DnsFailure -> StatusInsecure + NetworkIssue.VpnActive -> StatusWarning + NetworkIssue.LanRouteBlocked -> StatusWarning + NetworkIssue.Timeout -> StatusWarning + NetworkIssue.TlsFailure -> StatusCritical + NetworkIssue.AuthFailure -> StatusCritical + NetworkIssue.ApiFailure -> StatusCritical + NetworkIssue.RateLimited -> StatusWarning + NetworkIssue.Unknown -> StatusUnknown +} + private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--" private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--" private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--" diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlannerTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlannerTest.kt new file mode 100644 index 0000000..a1039a2 --- /dev/null +++ b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/RouteAttemptPlannerTest.kt @@ -0,0 +1,36 @@ +package net.rodakot.ngxhttpmonitoringclient.domain + +import net.rodakot.ngxhttpmonitoringclient.model.RouteKind +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouteAttemptPlannerTest { + @Test + fun attempts_usesSystemDnsWhenNoFallbacksOrVpn() { + assertEquals( + listOf(RouteKind.SystemDns), + RouteAttemptPlanner.attempts(hasFallbackAliases = false, vpnActive = false, lanAvailable = false), + ) + } + + @Test + fun attempts_addsFallbackDnsWhenAliasesExist() { + assertEquals( + listOf(RouteKind.SystemDns, RouteKind.FallbackDns), + RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = false, lanAvailable = false), + ) + } + + @Test + fun attempts_addsLanRoutesWhenVpnAndLanAreAvailable() { + assertEquals( + listOf( + RouteKind.SystemDns, + RouteKind.FallbackDns, + RouteKind.LanSystemDns, + RouteKind.LanFallbackDns, + ), + RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = true, lanAvailable = true), + ) + } +} diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt index 19eb000..ec814d4 100644 --- a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt +++ b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt @@ -24,4 +24,12 @@ class UrlRulesTest { UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"), ) } + + @Test + fun parseFallbackIps_acceptsCommaAndWhitespaceSeparatedValues() { + assertEquals( + listOf("192.168.1.20", "10.0.0.5"), + UrlRules.parseFallbackIps("192.168.1.20, 10.0.0.5\n192.168.1.20"), + ) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc70ad2..ab2cde2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" kotlin = "2.2.10" composeBom = "2026.02.01" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +29,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }