Skip to content

Commit 2fa4b30

Browse files
Add structured resolver scan results
Parse optional scan telemetry such as latency, attempts, and rejection reason while keeping older WD_SCAN lines compatible. Persist structured scan observations beside the legacy plain resolver file, rank successful results by score, and write recommended_resolvers.txt for future profile selection and diagnostics. Add focused tests for scan telemetry enrichment, resolver scoring, ranking, and JSON serialization.
1 parent 045fdeb commit 2fa4b30

6 files changed

Lines changed: 315 additions & 10 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package shop.whitedns.client.scan
2+
3+
import org.json.JSONObject
4+
5+
object ResolverScanResultStatus {
6+
const val Valid = "valid"
7+
const val Rejected = "rejected"
8+
}
9+
10+
data class ResolverScanResult(
11+
val resolver: String,
12+
val status: String,
13+
val sourceName: String = "",
14+
val serverDomain: String = "",
15+
val latencyMillis: Int? = null,
16+
val attempts: Int? = null,
17+
val reason: String = "",
18+
val observedAtMillis: Long = 0L,
19+
) {
20+
val isValid: Boolean
21+
get() = status == ResolverScanResultStatus.Valid
22+
23+
val score: Int
24+
get() = resolverScanScore(this)
25+
}
26+
27+
fun resolverScanScore(result: ResolverScanResult): Int {
28+
if (!result.isValid) {
29+
return 0
30+
}
31+
val latencyPenalty = when (val latency = result.latencyMillis) {
32+
null -> 12
33+
else -> (latency / 25).coerceIn(0, 48)
34+
}
35+
val attemptPenalty = ((result.attempts ?: 1) - 1).coerceAtLeast(0) * 6
36+
val confidenceBonus = when {
37+
result.latencyMillis != null && result.attempts != null -> 18
38+
result.latencyMillis != null -> 12
39+
result.attempts != null -> 6
40+
else -> 0
41+
}
42+
return (100 + confidenceBonus - latencyPenalty - attemptPenalty).coerceIn(1, 100)
43+
}
44+
45+
fun rankResolverScanResults(results: Iterable<ResolverScanResult>): List<ResolverScanResult> {
46+
return results
47+
.filter { it.isValid && it.resolver.isNotBlank() }
48+
.groupBy { it.resolver }
49+
.values
50+
.map { resolverResults ->
51+
resolverResults.maxWith(
52+
compareBy<ResolverScanResult> { it.score }
53+
.thenByDescending { it.observedAtMillis },
54+
)
55+
}
56+
.sortedWith(
57+
compareByDescending<ResolverScanResult> { it.score }
58+
.thenBy { it.latencyMillis ?: Int.MAX_VALUE }
59+
.thenByDescending { it.observedAtMillis }
60+
.thenBy { it.resolver },
61+
)
62+
}
63+
64+
fun ResolverScanResult.toJsonObject(): JSONObject {
65+
return JSONObject()
66+
.put("resolver", resolver)
67+
.put("status", status)
68+
.put("sourceName", sourceName)
69+
.put("serverDomain", serverDomain)
70+
.put("latencyMillis", latencyMillis)
71+
.put("attempts", attempts)
72+
.put("reason", reason)
73+
.put("observedAtMillis", observedAtMillis)
74+
.put("score", score)
75+
}
76+
77+
fun resolverScanResultFromJson(json: JSONObject): ResolverScanResult {
78+
return ResolverScanResult(
79+
resolver = json.optString("resolver"),
80+
status = json.optString("status"),
81+
sourceName = json.optString("sourceName"),
82+
serverDomain = json.optString("serverDomain"),
83+
latencyMillis = json.optionalPositiveInt("latencyMillis"),
84+
attempts = json.optionalPositiveInt("attempts"),
85+
reason = json.optString("reason"),
86+
observedAtMillis = json.optLong("observedAtMillis", 0L),
87+
)
88+
}
89+
90+
private fun JSONObject.optionalPositiveInt(name: String): Int? {
91+
if (!has(name) || isNull(name)) {
92+
return null
93+
}
94+
return optInt(name).takeIf { it > 0 }
95+
}

app/src/main/java/shop/whitedns/client/scan/StormDnsScanTelemetry.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
package shop.whitedns.client.scan
22

33
sealed class StormDnsScanTelemetry {
4-
data class Valid(val resolver: String) : StormDnsScanTelemetry()
5-
data class Rejected(val resolver: String) : StormDnsScanTelemetry()
4+
data class Valid(
5+
val resolver: String,
6+
val latencyMillis: Int? = null,
7+
val attempts: Int? = null,
8+
) : StormDnsScanTelemetry()
9+
10+
data class Rejected(
11+
val resolver: String,
12+
val reason: String = "",
13+
val latencyMillis: Int? = null,
14+
val attempts: Int? = null,
15+
) : StormDnsScanTelemetry()
16+
617
data class Complete(
718
val total: Int,
819
val valid: Int,
@@ -24,10 +35,23 @@ fun parseStormDnsScanLine(line: String): StormDnsScanTelemetry? {
2435
return when (fields["event"]) {
2536
"valid" -> fields["resolver"]
2637
?.takeIf(String::isNotBlank)
27-
?.let(StormDnsScanTelemetry::Valid)
38+
?.let { resolver ->
39+
StormDnsScanTelemetry.Valid(
40+
resolver = resolver,
41+
latencyMillis = fields.optionalPositiveInt("latency_ms", "latency"),
42+
attempts = fields.optionalPositiveInt("attempts"),
43+
)
44+
}
2845
"rejected" -> fields["resolver"]
2946
?.takeIf(String::isNotBlank)
30-
?.let(StormDnsScanTelemetry::Rejected)
47+
?.let { resolver ->
48+
StormDnsScanTelemetry.Rejected(
49+
resolver = resolver,
50+
reason = fields["reason"].orEmpty(),
51+
latencyMillis = fields.optionalPositiveInt("latency_ms", "latency"),
52+
attempts = fields.optionalPositiveInt("attempts"),
53+
)
54+
}
3155
"complete" -> StormDnsScanTelemetry.Complete(
3256
total = fields["total"].toIntOrZero(),
3357
valid = fields["valid"].toIntOrZero(),
@@ -41,6 +65,12 @@ private fun String?.toIntOrZero(): Int {
4165
return this?.toIntOrNull() ?: 0
4266
}
4367

68+
private fun Map<String, String>.optionalPositiveInt(vararg names: String): Int? {
69+
return names.firstNotNullOfOrNull { name ->
70+
this[name]?.toIntOrNull()?.takeIf { it > 0 }
71+
}
72+
}
73+
4474
private const val ScanMarker = "WD_SCAN"
4575
private val ScanFieldRegex = Regex("""(\w+)=([^\s]+)""")
4676
private val AnsiEscapeRegex = Regex("${27.toChar()}\\[[;?0-9]*[ -/]*[@-~]")

app/src/main/java/shop/whitedns/client/scan/WhiteDnsScanService.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ class WhiteDnsScanService : Service() {
242242
workerStats = workerStats,
243243
validResolvers = validResolvers,
244244
rejectedResolvers = rejectedResolvers,
245+
resultSourceName = sourceName,
246+
resultServerDomain = serverProfile.domain,
245247
stateLock = stateLock,
246248
publishAggregate = ::publishAggregate,
247249
)
@@ -295,6 +297,8 @@ class WhiteDnsScanService : Service() {
295297
workerStats: Array<WorkerScanStats>,
296298
validResolvers: MutableSet<String>,
297299
rejectedResolvers: MutableSet<String>,
300+
resultSourceName: String,
301+
resultServerDomain: String,
298302
stateLock: Any,
299303
publishAggregate: (String, String, Boolean) -> Unit,
300304
) {
@@ -325,6 +329,18 @@ class WhiteDnsScanService : Service() {
325329
if (added) {
326330
WhiteDnsScannerResultStore.appendValidResolvers(applicationContext, listOf(resolver))
327331
}
332+
WhiteDnsScannerResultStore.appendStructuredResult(
333+
applicationContext,
334+
ResolverScanResult(
335+
resolver = resolver,
336+
status = ResolverScanResultStatus.Valid,
337+
sourceName = resultSourceName,
338+
serverDomain = resultServerDomain,
339+
latencyMillis = telemetry.latencyMillis,
340+
attempts = telemetry.attempts,
341+
observedAtMillis = System.currentTimeMillis(),
342+
),
343+
)
328344
publishAggregate(WhiteDnsScanStatus.Running, "Found $validCount valid resolvers", false)
329345
}
330346
is StormDnsScanTelemetry.Rejected -> {
@@ -334,6 +350,19 @@ class WhiteDnsScanService : Service() {
334350
rejectedResolvers += resolver
335351
}
336352
}
353+
WhiteDnsScannerResultStore.appendStructuredResult(
354+
applicationContext,
355+
ResolverScanResult(
356+
resolver = resolver,
357+
status = ResolverScanResultStatus.Rejected,
358+
sourceName = resultSourceName,
359+
serverDomain = resultServerDomain,
360+
latencyMillis = telemetry.latencyMillis,
361+
attempts = telemetry.attempts,
362+
reason = telemetry.reason,
363+
observedAtMillis = System.currentTimeMillis(),
364+
),
365+
)
337366
publishAggregate(WhiteDnsScanStatus.Running, "Scanning", false)
338367
}
339368
is StormDnsScanTelemetry.Complete -> {
@@ -546,6 +575,11 @@ class WhiteDnsScanService : Service() {
546575
state.validResolverEntries.joinToString(separator = "\n"),
547576
Charsets.UTF_8,
548577
)
578+
File(resultsDir, "recommended_resolvers.txt").writeText(
579+
WhiteDnsScannerResultStore.readRecommendedResolvers(applicationContext)
580+
.joinToString(separator = "\n"),
581+
Charsets.UTF_8,
582+
)
549583
File(resultsDir, "rejected_resolvers.txt").writeText(
550584
state.rejectedResolverEntries.joinToString(separator = "\n"),
551585
Charsets.UTF_8,

app/src/main/java/shop/whitedns/client/scan/WhiteDnsScannerResultStore.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import android.util.AtomicFile
55
import java.io.File
66
import java.io.FileOutputStream
77
import java.io.IOException
8+
import org.json.JSONObject
89
import shop.whitedns.client.model.ResolverTextValidation
910
import shop.whitedns.client.model.validateResolverText
1011

1112
object WhiteDnsScannerResultStore {
1213
const val ResultFileName = "Scanner result"
14+
const val StructuredResultFileName = "scanner_results.jsonl"
1315
private val ResultFileLock = Any()
1416

1517
fun resultFile(context: Context): File {
@@ -20,6 +22,29 @@ object WhiteDnsScannerResultStore {
2022
return readValidResolverSet(context).toList()
2123
}
2224

25+
fun readRecommendedResolvers(context: Context, limit: Int = 64): List<String> {
26+
val structuredResults = readStructuredResults(context)
27+
if (structuredResults.isEmpty()) {
28+
return readValidResolvers(context).take(limit)
29+
}
30+
return rankResolverScanResults(structuredResults)
31+
.map { it.resolver }
32+
.take(limit.coerceAtLeast(1))
33+
}
34+
35+
fun readStructuredResults(context: Context): List<ResolverScanResult> {
36+
return runCatching {
37+
val file = structuredResultFile(context)
38+
if (!file.isFile) {
39+
emptyList()
40+
} else {
41+
file.bufferedReader(Charsets.UTF_8).useLines { lines ->
42+
lines.mapNotNull(::decodeStructuredResultLine).toList()
43+
}
44+
}
45+
}.getOrDefault(emptyList())
46+
}
47+
2348
fun readValidResolverSet(context: Context): Set<String> {
2449
return runCatching {
2550
val file = resultFile(context)
@@ -61,6 +86,21 @@ object WhiteDnsScannerResultStore {
6186
}
6287
}
6388

89+
fun appendStructuredResult(context: Context, result: ResolverScanResult) {
90+
val normalizedResolver = normalizeResolverEntry(result.resolver) ?: return
91+
val normalizedResult = result.copy(resolver = normalizedResolver)
92+
synchronized(ResultFileLock) {
93+
val target = structuredResultFile(context)
94+
target.parentFile?.mkdirs()
95+
FileOutputStream(target, true).use { stream ->
96+
if (target.length() > 0L) {
97+
stream.write("\n".toByteArray(Charsets.UTF_8))
98+
}
99+
stream.write(normalizedResult.toJsonObject().toString().toByteArray(Charsets.UTF_8))
100+
}
101+
}
102+
}
103+
64104
fun normalizeResolverText(rawText: String): List<String> {
65105
return normalizeScanResolverText(rawText).normalizedResolvers
66106
}
@@ -243,6 +283,20 @@ object WhiteDnsScannerResultStore {
243283
return File(File(context.noBackupFilesDir, "stormdns"), "scan")
244284
}
245285

286+
private fun structuredResultFile(context: Context): File {
287+
return File(resultDirectory(context), StructuredResultFileName)
288+
}
289+
290+
private fun decodeStructuredResultLine(line: String): ResolverScanResult? {
291+
val trimmed = line.trim()
292+
if (trimmed.isBlank()) {
293+
return null
294+
}
295+
return runCatching {
296+
resolverScanResultFromJson(JSONObject(trimmed))
297+
}.getOrNull()
298+
}
299+
246300
private fun stripScanResolverPort(resolver: String): String {
247301
val text = resolver.trim()
248302
val bracketedMatch = BracketedResolverPortRegex.matchEntire(text)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package shop.whitedns.client.scan
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class ResolverScanResultTest {
8+
@Test
9+
fun rankResolverScanResultsPrefersLowLatencySuccessfulResults() {
10+
val ranked = rankResolverScanResults(
11+
listOf(
12+
ResolverScanResult(
13+
resolver = "8.8.8.8",
14+
status = ResolverScanResultStatus.Valid,
15+
latencyMillis = 240,
16+
attempts = 2,
17+
observedAtMillis = 10L,
18+
),
19+
ResolverScanResult(
20+
resolver = "1.1.1.1",
21+
status = ResolverScanResultStatus.Valid,
22+
latencyMillis = 80,
23+
attempts = 1,
24+
observedAtMillis = 20L,
25+
),
26+
ResolverScanResult(
27+
resolver = "9.9.9.9",
28+
status = ResolverScanResultStatus.Rejected,
29+
reason = "timeout",
30+
observedAtMillis = 30L,
31+
),
32+
),
33+
)
34+
35+
assertEquals(listOf("1.1.1.1", "8.8.8.8"), ranked.map { it.resolver })
36+
assertTrue(ranked.first().score > ranked.last().score)
37+
}
38+
39+
@Test
40+
fun rankResolverScanResultsKeepsBestObservationPerResolver() {
41+
val ranked = rankResolverScanResults(
42+
listOf(
43+
ResolverScanResult(
44+
resolver = "1.1.1.1",
45+
status = ResolverScanResultStatus.Valid,
46+
latencyMillis = 200,
47+
attempts = 2,
48+
observedAtMillis = 10L,
49+
),
50+
ResolverScanResult(
51+
resolver = "1.1.1.1",
52+
status = ResolverScanResultStatus.Valid,
53+
latencyMillis = 70,
54+
attempts = 1,
55+
observedAtMillis = 20L,
56+
),
57+
),
58+
)
59+
60+
assertEquals(1, ranked.size)
61+
assertEquals(70, ranked.first().latencyMillis)
62+
}
63+
64+
@Test
65+
fun resolverScanResultRoundTripsJson() {
66+
val result = ResolverScanResult(
67+
resolver = "1.1.1.1",
68+
status = ResolverScanResultStatus.Valid,
69+
sourceName = "Default list",
70+
serverDomain = "server.example.com",
71+
latencyMillis = 90,
72+
attempts = 1,
73+
observedAtMillis = 42L,
74+
)
75+
76+
assertEquals(result, resolverScanResultFromJson(result.toJsonObject()))
77+
}
78+
}

0 commit comments

Comments
 (0)