Skip to content

Commit 99df53b

Browse files
committed
v4.2.0: Fix DNS log data starvation, IPv6 DoH, fd error recovery, CNAME logging
- logAsyncRich: new overload writes cnameChain, resolvedIps, responseTimeMs, upstreamServer to DnsLogEntry — detail sheet now shows real data - forwardUdp/forwardDoH/forwardUdpV6: extract CNAME chains + answer IPs from response, pass to logAsyncRich with latency and upstream server label - CNAME-blocked domains now logged (were silently counted without log entry) - fdErrorCount: incremented on POLLERR/POLLHUP and ErrnoException - packetLoop: auto-restarts VPN on unexpected exit while isRunning=true - processIpv6Dns: honours useDoH flag (was always plaintext), adds cache lookup - forwardDoH: dual-mode wrapV6 parameter for IPv6 packet wrapping - DohBypassUpdater: inject shared OkHttpClient instead of creating own instance - App context (package/label) threaded through all forward methods
1 parent 5315dc3 commit 99df53b

4 files changed

Lines changed: 122 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# HostShield
22

33
## Overview
4-
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.1.0.
4+
Modern, AMOLED-dark hosts-based ad blocker app for Android. Inspired by AdAway. v4.2.0.
55

66
## Tech Stack
77
- Kotlin, Jetpack Compose, Material 3
@@ -70,6 +70,7 @@ cd app
7070
- Secrets configured: `KEYSTORE_BASE64`, `KEY_ALIAS`, `KEY_PASSWORD`, `STORE_PASSWORD`
7171

7272
## Version History
73+
- v4.2.0: Fix DNS log data starvation (CNAME chains, resolved IPs, latency, upstream server now written to DB), CNAME-blocked domains now logged, fd error tracking + auto-restart on TUN error, IPv6 DoH support (honours useDoH flag), IPv6 DNS cache lookup, DohBypassUpdater uses shared OkHttpClient, app context threaded through all forward methods
7374
- v4.1.0: Custom upstream DNS UI in Settings, firewall rule export/import (JSON), automation audit log viewer screen, query rate anomaly detection (3x baseline warning on Home), dropped queries banner, cache hit rate on Home
7475
- v4.0.0: Automation API rate limiting + audit logging, GeoIP rate limit backoff, shared OkHttpClient pooling, tracker scanner Room caching, VPN stability metrics (uptime/rebuilds/errors/drops), DNS cache stats in Stats screen, VPN Health card, log buffer overflow detection, DB v9
7576
- v3.9.0: Private DNS conflict warning in onboarding, smart DNS latency-based failover, GeoIP/ASN/country in DNS log details, automation broadcast security fix, IPv6 TCP DNS support
@@ -106,3 +107,7 @@ cd app
106107
- Firewall export UIDs are device-specific — importer must resolve UIDs from package names
107108
- Query anomaly baseline needs 10 samples (~50s) before detection activates
108109
- DnsVpnService.currentCacheStats and currentDroppedQueries are companion @Volatile fields polled by UI
110+
- forwardUdp/forwardDoH/forwardUdpV6 all log rich data (CNAME, IPs, latency) — processIpv4Dns/v6 only log basic for blocked/cache hits
111+
- processIpv6Dns now has DoH + cache lookup (was plaintext-only before v4.2.0)
112+
- forwardDoH is dual-mode: `wrapV6=true` wraps response as IPv6 packet instead of IPv4
113+
- packetLoop auto-restarts VPN on unexpected exit (fd error) while `isRunning=true`

app/app/build.gradle.kts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// HostShield v4.1.0
1+
// HostShield v4.2.0
22
plugins {
33
id("com.android.application")
44
id("org.jetbrains.kotlin.android")
@@ -15,8 +15,8 @@ android {
1515
applicationId = "com.hostshield"
1616
minSdk = 26
1717
targetSdk = 35
18-
versionCode = 41
19-
versionName = "4.1.0"
18+
versionCode = 42
19+
versionName = "4.2.0"
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222

app/app/src/main/java/com/hostshield/service/DnsVpnService.kt

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -708,11 +708,13 @@ class DnsVpnService : VpnService() {
708708
// Check for error conditions on TUN fd
709709
if (pollFds[0].revents.toInt() and (OsConstants.POLLERR or OsConstants.POLLHUP) != 0) {
710710
Log.w(TAG, "packetLoop: TUN fd error/hangup")
711+
fdErrorCount.incrementAndGet()
711712
break
712713
}
713714
} catch (e: ErrnoException) {
714715
if (e.errno == OsConstants.EINTR) continue // interrupted by signal, retry
715716
if (!isRunning) break
717+
fdErrorCount.incrementAndGet()
716718
Log.e(TAG, "Poll error: ${e.message}")
717719
delay(10)
718720
} catch (e: Exception) {
@@ -722,6 +724,11 @@ class DnsVpnService : VpnService() {
722724
}
723725
}
724726
Log.i(TAG, "packetLoop exited after $count packets")
727+
// Auto-restart on fd error (not clean shutdown)
728+
if (isRunning) {
729+
Log.w(TAG, "packetLoop exited unexpectedly while running — restarting VPN")
730+
restartVpn()
731+
}
725732
}
726733

727734
private suspend fun processIpv4Dns(packet: ByteArray, length: Int) {
@@ -757,9 +764,9 @@ class DnsVpnService : VpnService() {
757764
}
758765

759766
val blocked = isDomainBlocked(domain)
760-
logAsync(domain, blocked, app, qtype)
761767

762768
if (blocked) {
769+
logAsync(domain, true, app, qtype)
763770
Log.d(TAG, "BLOCKED $domain ($qtype) [${app.second.ifEmpty { "system" }}]")
764771
sendBlockResponse(dns, packet, ihl, false, qtype)
765772
} else {
@@ -769,15 +776,17 @@ class DnsVpnService : VpnService() {
769776
val cached = dnsCache.get(domain, qtypeNum, txId)
770777
if (cached != null) {
771778
Log.d(TAG, "CACHE HIT $domain ($qtype)")
779+
logAsync(domain, false, app, qtype) // basic log for cache hits
772780
wrapResponseV4(packet, ihl, cached)?.let { writeChannel.send(it) }
773781
allowedCount++
774782
return
775783
}
776784

785+
// Rich log with CNAME/IPs/latency happens inside forward methods
777786
Log.d(TAG, "ALLOWED $domain ($qtype)")
778787
val pCopy = packet.copyOf(length)
779-
if (useDoH) serviceScope.launch { forwardDoH(dns, domain, pCopy, ihl) }
780-
else serviceScope.launch { forwardUdp(dns, domain, pCopy, ihl) }
788+
if (useDoH) serviceScope.launch { forwardDoH(dns, domain, pCopy, ihl, app) }
789+
else serviceScope.launch { forwardUdp(dns, domain, pCopy, ihl, app) }
781790
allowedCount++
782791
}
783792
}
@@ -819,16 +828,28 @@ class DnsVpnService : VpnService() {
819828
}
820829

821830
val blocked = isDomainBlocked(domain)
822-
logAsync(domain, blocked, app, qtype)
823831

824832
if (blocked) {
833+
logAsync(domain, true, app, qtype)
825834
val resp = buildBlockResponse(dns, qtype) ?: return
826835
val wrapped = wrapResponseV6(packet, hdr, resp) ?: return
827836
writeChannel.send(wrapped); blockedCount++
828837
if (blockedCount % 100 == 0) updateNotification(blockedCount)
829838
} else {
839+
// Cache lookup
840+
val qtypeNum = DnsPacketBuilder.parseQueryType(dns)
841+
val txId = if (dns.size >= 2) byteArrayOf(dns[0], dns[1]) else byteArrayOf(0, 0)
842+
val cached = dnsCache.get(domain, qtypeNum, txId)
843+
if (cached != null) {
844+
Log.d(TAG, "CACHE HIT (v6) $domain ($qtype)")
845+
wrapResponseV6(packet, hdr, cached)?.let { writeChannel.send(it) }
846+
allowedCount++
847+
return
848+
}
849+
830850
val pCopy = packet.copyOf(length)
831-
serviceScope.launch { forwardUdpV6(dns, domain, pCopy, hdr) }
851+
if (useDoH) serviceScope.launch { forwardDoH(dns, domain, pCopy, 0, app, wrapV6 = true, v6Hdr = hdr) }
852+
else serviceScope.launch { forwardUdpV6(dns, domain, pCopy, hdr, app) }
832853
allowedCount++
833854
}
834855
}
@@ -862,12 +883,23 @@ class DnsVpnService : VpnService() {
862883
}
863884

864885
private fun logAsync(domain: String, blocked: Boolean, app: Pair<String, String>, qtype: String) {
886+
logAsyncRich(domain, blocked, app, qtype)
887+
}
888+
889+
/** Rich log entry with CNAME chain, resolved IPs, latency, and upstream server. */
890+
private fun logAsyncRich(
891+
domain: String, blocked: Boolean, app: Pair<String, String>, qtype: String,
892+
cnameChain: String = "", resolvedIps: String = "",
893+
responseTimeMs: Int = 0, upstreamServer: String = ""
894+
) {
865895
// Always count stats even if logging disabled
866896
if (blocked) pendingBlockedStats.incrementAndGet() else pendingAllowedStats.incrementAndGet()
867897

868898
val entry = DnsLogEntry(
869899
hostname = domain, blocked = blocked,
870-
appPackage = app.first, appLabel = app.second, queryType = qtype
900+
appPackage = app.first, appLabel = app.second, queryType = qtype,
901+
cnameChain = cnameChain, resolvedIps = resolvedIps,
902+
responseTimeMs = responseTimeMs, upstreamServer = upstreamServer
871903
)
872904

873905
// Emit to live query stream (non-blocking, drops oldest if full)
@@ -1433,7 +1465,8 @@ class DnsVpnService : VpnService() {
14331465

14341466
// ── DNS Forwarding ───────────────────────────────────────
14351467

1436-
private suspend fun forwardUdp(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int) {
1468+
private suspend fun forwardUdp(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int,
1469+
app: Pair<String, String> = Pair("", "")) {
14371470
try {
14381471
val startMs = System.currentTimeMillis()
14391472
val sock = DatagramSocket(); protect(sock)
@@ -1445,11 +1478,15 @@ class DnsVpnService : VpnService() {
14451478
sock.receive(rp); sock.close()
14461479
val respBytes = buf.copyOf(rp.length)
14471480
val latencyMs = (System.currentTimeMillis() - startMs).toInt()
1481+
val qtype = parseDnsQueryType(dns)
14481482

14491483
// CNAME cloaking detection — block if any CNAME target is in blocklist
14501484
val cnameResult = CnameCloakDetector.inspect(respBytes, blocklist)
14511485
if (cnameResult.blocked) {
14521486
Log.i(TAG, "CNAME CLOAK blocked: $domain -> ${cnameResult.blockedCname}")
1487+
logAsyncRich(domain, true, app, qtype,
1488+
cnameChain = cnameResult.cnameChain.joinToString(","),
1489+
responseTimeMs = latencyMs, upstreamServer = primary)
14531490
val blockResp = buildBlockResponse(dns, DnsPacketBuilder.parseQueryType(dns).let {
14541491
when (it) { 1 -> "A"; 28 -> "AAAA"; else -> "A" }
14551492
})
@@ -1458,29 +1495,42 @@ class DnsVpnService : VpnService() {
14581495
return
14591496
}
14601497

1498+
// Log rich data for allowed queries
1499+
val resolvedIps = CnameCloakDetector.extractAnswerIps(respBytes)
1500+
logAsyncRich(domain, false, app, qtype,
1501+
cnameChain = cnameResult.cnameChain.joinToString(","),
1502+
resolvedIps = resolvedIps.joinToString(","),
1503+
responseTimeMs = latencyMs, upstreamServer = primary)
1504+
14611505
cacheDnsAnswerIps(domain, respBytes)
1462-
// Cache the response
14631506
val qtypeNum = DnsPacketBuilder.parseQueryType(dns)
14641507
dnsCache.put(domain, qtypeNum, respBytes)
14651508

14661509
wrapResponseV4(orig, ihl, respBytes)?.let { writeChannel.send(it) }
14671510
} catch (_: java.net.SocketTimeoutException) {
1468-
sock.close(); forwardUdpFallback(dns, domain, orig, ihl)
1511+
sock.close(); forwardUdpFallback(dns, domain, orig, ihl, app)
14691512
}
14701513
} catch (_: Exception) { }
14711514
}
14721515

1473-
private suspend fun forwardUdpFallback(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int) {
1516+
private suspend fun forwardUdpFallback(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int,
1517+
app: Pair<String, String> = Pair("", "")) {
14741518
try {
1519+
val startMs = System.currentTimeMillis()
14751520
val fallback = upstreamDnsServers.getOrElse(1) { UPSTREAM_DNS[1] }
14761521
val sock = DatagramSocket(); protect(sock); sock.soTimeout = 5000
14771522
sock.send(DatagramPacket(dns, dns.size, InetAddress.getByName(fallback), DNS_PORT))
14781523
val buf = ByteArray(1500); val rp = DatagramPacket(buf, buf.size)
14791524
sock.receive(rp); sock.close()
14801525
val respBytes = buf.copyOf(rp.length)
1526+
val latencyMs = (System.currentTimeMillis() - startMs).toInt()
1527+
val qtype = parseDnsQueryType(dns)
14811528

14821529
val cnameResult = CnameCloakDetector.inspect(respBytes, blocklist)
14831530
if (cnameResult.blocked) {
1531+
logAsyncRich(domain, true, app, qtype,
1532+
cnameChain = cnameResult.cnameChain.joinToString(","),
1533+
responseTimeMs = latencyMs, upstreamServer = "$fallback (fallback)")
14841534
val blockResp = buildBlockResponse(dns, DnsPacketBuilder.parseQueryType(dns).let {
14851535
when (it) { 1 -> "A"; 28 -> "AAAA"; else -> "A" }
14861536
})
@@ -1489,49 +1539,88 @@ class DnsVpnService : VpnService() {
14891539
return
14901540
}
14911541

1542+
val resolvedIps = CnameCloakDetector.extractAnswerIps(respBytes)
1543+
logAsyncRich(domain, false, app, qtype,
1544+
cnameChain = cnameResult.cnameChain.joinToString(","),
1545+
resolvedIps = resolvedIps.joinToString(","),
1546+
responseTimeMs = latencyMs, upstreamServer = "$fallback (fallback)")
1547+
14921548
cacheDnsAnswerIps(domain, respBytes)
14931549
dnsCache.put(domain, DnsPacketBuilder.parseQueryType(dns), respBytes)
14941550
wrapResponseV4(orig, ihl, respBytes)?.let { writeChannel.send(it) }
14951551
} catch (_: Exception) { }
14961552
}
14971553

1498-
private suspend fun forwardDoH(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int) {
1554+
private suspend fun forwardDoH(dns: ByteArray, domain: String, orig: ByteArray, ihl: Int,
1555+
app: Pair<String, String> = Pair("", ""),
1556+
wrapV6: Boolean = false, v6Hdr: Int = 0) {
14991557
try {
1558+
val startMs = System.currentTimeMillis()
15001559
val resp = dohResolver.resolve(dns, dohProvider)
15011560
if (resp != null) {
1561+
val latencyMs = (System.currentTimeMillis() - startMs).toInt()
1562+
val qtype = parseDnsQueryType(dns)
1563+
val upstreamLabel = "DoH:${dohProvider.name}"
1564+
15021565
// CNAME cloaking detection
15031566
val cnameResult = CnameCloakDetector.inspect(resp, blocklist)
15041567
if (cnameResult.blocked) {
15051568
Log.i(TAG, "CNAME CLOAK (DoH) blocked: $domain -> ${cnameResult.blockedCname}")
1569+
logAsyncRich(domain, true, app, qtype,
1570+
cnameChain = cnameResult.cnameChain.joinToString(","),
1571+
responseTimeMs = latencyMs, upstreamServer = upstreamLabel)
15061572
val blockResp = buildBlockResponse(dns, DnsPacketBuilder.parseQueryType(dns).let {
15071573
when (it) { 1 -> "A"; 28 -> "AAAA"; else -> "A" }
15081574
})
1509-
if (blockResp != null) wrapResponseV4(orig, ihl, blockResp)?.let { writeChannel.send(it) }
1575+
if (blockResp != null) {
1576+
if (wrapV6) wrapResponseV6(orig, v6Hdr, blockResp)?.let { writeChannel.send(it) }
1577+
else wrapResponseV4(orig, ihl, blockResp)?.let { writeChannel.send(it) }
1578+
}
15101579
blockedCount++
15111580
return
15121581
}
15131582

1583+
val resolvedIps = CnameCloakDetector.extractAnswerIps(resp)
1584+
logAsyncRich(domain, false, app, qtype,
1585+
cnameChain = cnameResult.cnameChain.joinToString(","),
1586+
resolvedIps = resolvedIps.joinToString(","),
1587+
responseTimeMs = latencyMs, upstreamServer = upstreamLabel)
1588+
15141589
cacheDnsAnswerIps(domain, resp)
15151590
val qtypeNum = DnsPacketBuilder.parseQueryType(dns)
15161591
dnsCache.put(domain, qtypeNum, resp)
15171592

1518-
wrapResponseV4(orig, ihl, resp)?.let { writeChannel.send(it) }
1593+
if (wrapV6) wrapResponseV6(orig, v6Hdr, resp)?.let { writeChannel.send(it) }
1594+
else wrapResponseV4(orig, ihl, resp)?.let { writeChannel.send(it) }
1595+
}
1596+
else {
1597+
if (wrapV6) forwardUdpV6(dns, domain, orig, v6Hdr, app)
1598+
else forwardUdp(dns, domain, orig, ihl, app)
15191599
}
1520-
else forwardUdp(dns, domain, orig, ihl) // DoH failed, fallback to plaintext
1521-
} catch (_: Exception) { forwardUdp(dns, domain, orig, ihl) }
1600+
} catch (_: Exception) {
1601+
if (wrapV6) forwardUdpV6(dns, domain, orig, v6Hdr, app)
1602+
else forwardUdp(dns, domain, orig, ihl, app)
1603+
}
15221604
}
15231605

1524-
private suspend fun forwardUdpV6(dns: ByteArray, domain: String, orig: ByteArray, hdr: Int) {
1606+
private suspend fun forwardUdpV6(dns: ByteArray, domain: String, orig: ByteArray, hdr: Int,
1607+
app: Pair<String, String> = Pair("", "")) {
15251608
try {
1609+
val startMs = System.currentTimeMillis()
15261610
val primary = upstreamDnsServers.firstOrNull() ?: UPSTREAM_DNS[0]
15271611
val sock = DatagramSocket(); protect(sock); sock.soTimeout = 5000
15281612
sock.send(DatagramPacket(dns, dns.size, InetAddress.getByName(primary), DNS_PORT))
15291613
val buf = ByteArray(1500); val rp = DatagramPacket(buf, buf.size)
15301614
sock.receive(rp); sock.close()
15311615
val respBytes = buf.copyOf(rp.length)
1616+
val latencyMs = (System.currentTimeMillis() - startMs).toInt()
1617+
val qtype = parseDnsQueryType(dns)
15321618

15331619
val cnameResult = CnameCloakDetector.inspect(respBytes, blocklist)
15341620
if (cnameResult.blocked) {
1621+
logAsyncRich(domain, true, app, qtype,
1622+
cnameChain = cnameResult.cnameChain.joinToString(","),
1623+
responseTimeMs = latencyMs, upstreamServer = primary)
15351624
val blockResp = buildBlockResponse(dns, DnsPacketBuilder.parseQueryType(dns).let {
15361625
when (it) { 1 -> "A"; 28 -> "AAAA"; else -> "A" }
15371626
})
@@ -1540,6 +1629,12 @@ class DnsVpnService : VpnService() {
15401629
return
15411630
}
15421631

1632+
val resolvedIps = CnameCloakDetector.extractAnswerIps(respBytes)
1633+
logAsyncRich(domain, false, app, qtype,
1634+
cnameChain = cnameResult.cnameChain.joinToString(","),
1635+
resolvedIps = resolvedIps.joinToString(","),
1636+
responseTimeMs = latencyMs, upstreamServer = primary)
1637+
15431638
cacheDnsAnswerIps(domain, respBytes)
15441639
dnsCache.put(domain, DnsPacketBuilder.parseQueryType(dns), respBytes)
15451640
wrapResponseV6(orig, hdr, respBytes)?.let { writeChannel.send(it) }

app/app/src/main/java/com/hostshield/service/DohBypassUpdater.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Dispatchers
77
import kotlinx.coroutines.withContext
88
import okhttp3.OkHttpClient
99
import okhttp3.Request
10-
import java.util.concurrent.TimeUnit
1110
import javax.inject.Inject
1211
import javax.inject.Singleton
1312

@@ -35,7 +34,8 @@ import javax.inject.Singleton
3534
*/
3635
@Singleton
3736
class DohBypassUpdater @Inject constructor(
38-
private val prefs: AppPreferences
37+
private val prefs: AppPreferences,
38+
private val client: OkHttpClient
3939
) {
4040
companion object {
4141
private const val TAG = "DohBypassUpdater"
@@ -45,11 +45,6 @@ class DohBypassUpdater @Inject constructor(
4545
private const val MAX_JSON_SIZE = 50_000L // 50KB max
4646
}
4747

48-
private val client = OkHttpClient.Builder()
49-
.connectTimeout(10, TimeUnit.SECONDS)
50-
.readTimeout(10, TimeUnit.SECONDS)
51-
.build()
52-
5348
data class RemoteList(
5449
val version: Int = 0,
5550
val updated: String = "",

0 commit comments

Comments
 (0)