Skip to content

Commit 7147f8d

Browse files
committed
Add TTL-based DoH cache for desktop and Android
1 parent 3c27109 commit 7147f8d

2 files changed

Lines changed: 184 additions & 34 deletions

File tree

android/app/src/main/java/io/linuxdo/accelerator/android/LinuxdoDns.kt

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import java.net.InetAddress
1313
import java.net.URL
1414
import java.util.Base64
1515
import java.util.Locale
16+
import java.util.concurrent.ConcurrentHashMap
1617
import java.util.concurrent.TimeUnit
1718
import android.util.Log
19+
import android.os.SystemClock
1820

1921
data class ParsedDnsQuery(
2022
val id: Int,
@@ -35,6 +37,16 @@ data class DnsResolution(
3537
val responseCode: Int = 0,
3638
)
3739

40+
data class CachedDnsResolution(
41+
val resolution: DnsResolution,
42+
val expiresAtMs: Long,
43+
)
44+
45+
data class DnsCacheKey(
46+
val host: String,
47+
val type: Int,
48+
)
49+
3850
data class PreparedDohEndpoint(
3951
val rawUrl: String,
4052
val host: String,
@@ -47,6 +59,7 @@ class LinuxdoDnsResolver(
4759
endpoints: List<PreparedDohEndpoint>,
4860
) {
4961
private val tag = "LinuxdoDnsResolver"
62+
private val cache = ConcurrentHashMap<DnsCacheKey, CachedDnsResolution>()
5063
private val client = OkHttpClient.Builder()
5164
.connectTimeout(10, TimeUnit.SECONDS)
5265
.readTimeout(10, TimeUnit.SECONDS)
@@ -57,25 +70,34 @@ class LinuxdoDnsResolver(
5770

5871
fun resolveManagedPayload(requestPayload: ByteArray, query: ParsedDnsQuery): ByteArray {
5972
val host = query.name.lowercase(Locale.US)
73+
readCached(host, query.type)?.let { cached ->
74+
Log.d(tag, "cache hit for $host type=${query.type} answers=${cached.answers.size}")
75+
return DnsPacketCodec.buildResponse(query, cached)
76+
}
77+
6078
resolveLocal(host, query.type)?.let { answers ->
79+
val resolution = DnsResolution(answers)
80+
writeCache(host, query.type, resolution)
6181
Log.d(tag, "resolved local override for $host type=${query.type} answers=${answers.size}")
62-
return DnsPacketCodec.buildResponse(query, DnsResolution(answers))
82+
return DnsPacketCodec.buildResponse(query, resolution)
6383
}
6484

6585
var lastError: Exception? = null
6686
for (endpoint in preparedEndpoints) {
6787
try {
6888
if (supportsDnsMessage(endpoint.url)) {
69-
val payload = queryDohDnsMessageRaw(endpoint, requestPayload)
89+
val resolution = queryDohDnsMessageRaw(endpoint, requestPayload, query)
90+
writeCache(host, query.type, resolution)
7091
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} raw dns-message")
71-
return payload
92+
return DnsPacketCodec.buildResponse(query, resolution)
7293
}
7394

7495
if (isJsonFriendlyType(query.type)) {
75-
val answers = queryDohJson(endpoint, host, query.type)
76-
if (answers.isNotEmpty()) {
77-
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${answers.size}")
78-
return DnsPacketCodec.buildResponse(query, DnsResolution(answers))
96+
val resolution = DnsResolution(queryDohJson(endpoint, host, query.type))
97+
if (resolution.answers.isNotEmpty()) {
98+
writeCache(host, query.type, resolution)
99+
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
100+
return DnsPacketCodec.buildResponse(query, resolution)
79101
}
80102
lastError = IOException("empty answer from ${endpoint.rawUrl}")
81103
continue
@@ -97,8 +119,17 @@ class LinuxdoDnsResolver(
97119
}
98120

99121
val host = query.name.lowercase(Locale.US)
122+
readCached(host, query.type)?.let {
123+
Log.d(tag, "cache hit for $host type=${query.type} answers=${it.answers.size}")
124+
return it
125+
}
126+
100127
val managedHost = config.isManagedHost(host)
101-
resolveLocal(host, query.type)?.let { return DnsResolution(it) }
128+
resolveLocal(host, query.type)?.let {
129+
val resolution = DnsResolution(it)
130+
writeCache(host, query.type, resolution)
131+
return resolution
132+
}
102133

103134
val endpoints = if (managedHost) {
104135
preparedEndpoints
@@ -109,10 +140,11 @@ class LinuxdoDnsResolver(
109140
var lastError: Exception? = null
110141
for (endpoint in endpoints) {
111142
try {
112-
val answers = queryDoh(endpoint, host, query.type)
113-
if (answers.isNotEmpty()) {
114-
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${answers.size}")
115-
return DnsResolution(answers)
143+
val resolution = DnsResolution(queryDoh(endpoint, host, query.type))
144+
if (resolution.answers.isNotEmpty()) {
145+
writeCache(host, query.type, resolution)
146+
Log.d(tag, "resolved $host type=${query.type} via ${endpoint.rawUrl} answers=${resolution.answers.size}")
147+
return resolution
116148
}
117149
lastError = IOException("empty answer from ${endpoint.rawUrl}")
118150
} catch (error: Exception) {
@@ -123,6 +155,7 @@ class LinuxdoDnsResolver(
123155

124156
throw IOException("DoH lookup failed for $host: ${lastError?.message ?: "no endpoint available"}")
125157
}
158+
126159
private fun resolveLocal(host: String, type: Int): List<DnsAnswerRecord>? {
127160
config.findDnsHostOverride(host)?.let { override ->
128161
return resolveOverride(override, type)
@@ -256,7 +289,8 @@ class LinuxdoDnsResolver(
256289
private fun queryDohDnsMessageRaw(
257290
endpoint: PreparedDohEndpoint,
258291
payload: ByteArray,
259-
): ByteArray {
292+
query: ParsedDnsQuery,
293+
): DnsResolution {
260294
val encoded = Base64.getUrlEncoder()
261295
.withoutPadding()
262296
.encodeToString(payload)
@@ -272,8 +306,32 @@ class LinuxdoDnsResolver(
272306
if (!response.isSuccessful) {
273307
throw IOException("DoH server ${endpoint.rawUrl} returned ${response.code}")
274308
}
275-
return response.body?.bytes() ?: throw IOException("empty DoH response from ${endpoint.rawUrl}")
309+
val body = response.body?.bytes() ?: throw IOException("empty DoH response from ${endpoint.rawUrl}")
310+
return DnsResolution(DnsPacketCodec.parseWireResponse(body, query.type))
311+
}
312+
}
313+
314+
private fun readCached(host: String, type: Int): DnsResolution? {
315+
val key = DnsCacheKey(host, type)
316+
val entry = cache[key] ?: return null
317+
if (SystemClock.elapsedRealtime() >= entry.expiresAtMs) {
318+
cache.remove(key)
319+
return null
276320
}
321+
return entry.resolution
322+
}
323+
324+
private fun writeCache(host: String, type: Int, resolution: DnsResolution) {
325+
val minTtl = resolution.answers.minOfOrNull { it.ttl } ?: return
326+
if (minTtl <= 0) {
327+
cache.remove(DnsCacheKey(host, type))
328+
return
329+
}
330+
cache.entries.removeIf { (_, entry) -> SystemClock.elapsedRealtime() >= entry.expiresAtMs }
331+
cache[DnsCacheKey(host, type)] = CachedDnsResolution(
332+
resolution = resolution,
333+
expiresAtMs = SystemClock.elapsedRealtime() + (minTtl.toLong() * 1000L),
334+
)
277335
}
278336

279337
private fun supportsDnsMessage(url: URL): Boolean {

0 commit comments

Comments
 (0)