Implemented VPN/DNS resilience
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ServerProfile> {
|
||||
@@ -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<String, RouteDiagnostics> {
|
||||
val result = linkedMapOf<String, RouteDiagnostics>()
|
||||
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>): String {
|
||||
return listToJson(tags)
|
||||
}
|
||||
|
||||
private fun listToJson(values: List<String>): 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<String> = runCatching {
|
||||
private fun tagsFromJson(value: String): List<String> = listFromJson(value)
|
||||
|
||||
private fun listFromJson(value: String): List<String> = 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)
|
||||
|
||||
@@ -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<String>,
|
||||
private val delegate: Dns,
|
||||
) : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
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<InetAddress> {
|
||||
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)
|
||||
|
||||
@@ -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<ServerProfile> = database.listServers()
|
||||
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
|
||||
|
||||
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
|
||||
|
||||
fun routeDiagnostics(): Map<String, net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics> = database.routeDiagnostics()
|
||||
|
||||
fun alerts(): List<AlertEvent> = 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<String> = 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
|
||||
|
||||
@@ -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<RouteKind> {
|
||||
val result = mutableListOf(RouteKind.SystemDns)
|
||||
if (hasFallbackAliases) result += RouteKind.FallbackDns
|
||||
if (vpnActive && lanAvailable) {
|
||||
result += RouteKind.LanSystemDns
|
||||
if (hasFallbackAliases) result += RouteKind.LanFallbackDns
|
||||
}
|
||||
return result.distinct()
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
return input
|
||||
.split(',', '\n', ' ')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.onEach { value ->
|
||||
runCatching { InetAddress.getByName(value) }
|
||||
.getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ data class ServerProfile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val fallbackIpAddresses: List<String> = emptyList(),
|
||||
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
|
||||
val tags: List<String> = 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<ServerProfile> = emptyList(),
|
||||
val summaries: Map<String, MetricSummary> = emptyMap(),
|
||||
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
||||
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
|
||||
val alerts: List<AlertEvent> = emptyList(),
|
||||
val selectedServerId: String? = null,
|
||||
val showDetail: Boolean = false,
|
||||
|
||||
@@ -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) } ?: "--"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user