Below is the distilled “field-notes” version, with the minimum set of changes that finally made web pages load on both Wi-Fi and LTE while still blocking everything that isn’t on the whitelist.
Option | What it does ? | Effort | Battery |
---|---|---|---|
DNS-only allow-list (recommended) | Let Android route traffic as usual, but fail every DNS lookup whose FQDN is not on your list. | Minimal | Minimal |
Full user-space forwarder | Suck all packets into the TUN, recreate a TCP/UDP stack in Kotlin, forward bytes in both directions. | Maximum | Maximum |
Unless you need DPI or per-packet accounting, stick to DNS filtering first. You can always tighten the net later.
class SecureThread(private val vpn: VpnService) : Runnable {
private val dnsAllow = hashSetOf(
"sentry.io", "mapbox.com", "posthog.com", "time.android.com",
"fonts.google.com", "wikipedia.org"
)
private lateinit var tunFd: ParcelFileDescriptor
private lateinit var inStream: FileInputStream
private lateinit var outStream: FileOutputStream
private val buf = ByteArray(32 * 1024)
// Always use a public resolver – carrier DNS often hides behind 10.x / 192.168.x
private val resolver = InetSocketAddress("1.1.1.1", 53)
override fun run() {
tunFd = buildTun()
inStream = FileInputStream(tunFd.fileDescriptor)
outStream = FileOutputStream(tunFd.fileDescriptor)
val dnsSocket = DatagramSocket().apply { vpn.protect(this) }
dnsSocket.soTimeout = 5_000 // don’t hang forever on bad networks
while (!Thread.currentThread().isInterrupted) {
val len = inStream.read(buf)
if (len <= 0) continue
val pkt = IpV4Packet.newPacket(buf, 0, len)
val udp = pkt.payload as? UdpPacket ?: passthrough(pkt)
if (udp?.header?.dstPort?.valueAsInt() != 53) { passthrough(pkt); continue }
val dns = Message(udp.payload.rawData)
val qName = dns.question.name.toString(true)
if (dnsAllow.none { qName.endsWith(it) }) {
// Synthesize NXDOMAIN
dns.header.rcode = Rcode.NXDOMAIN
reply(pkt, dns.toWire())
continue
}
// Forward to 1.1.1.1
val fwd = DatagramPacket(udp.payload.rawData, udp.payload.rawData.size, resolver)
dnsSocket.send(fwd)
val respBuf = ByteArray(1500)
val respPkt = DatagramPacket(respBuf, respBuf.size)
dnsSocket.receive(respPkt)
reply(pkt, respBuf.copyOf(respPkt.length))
}
}
/* - helpers -*/
private fun buildTun(): ParcelFileDescriptor =
vpn.Builder()
.setSession("Whitelist-DNS")
.setMtu(1280) // safe for cellular
.addAddress("10.0.0.2", 24) // dummy, but required
.addDnsServer("1.1.1.1") // force all lookups through us
.establish()
private fun passthrough(ip: IpV4Packet) = outStream.write(ip.rawData)
private fun reply(request: IpV4Packet, payload: ByteArray) {
val udp = request.payload as UdpPacket
val answer =
UdpPacket.Builder(udp)
.srcPort(udp.header.dstPort)
.dstPort(udp.header.srcPort)
.srcAddr(request.header.dstAddr)
.dstAddr(request.header.srcAddr)
.payloadBuilder(UnknownPacket.Builder().rawData(payload))
.correctChecksumAtBuild(true)
.correctLengthAtBuild(true)
val ip =
IpV4Packet.Builder(request)
.srcAddr(request.header.dstAddr)
.dstAddr(request.header.srcAddr)
.payloadBuilder(answer)
.correctChecksumAtBuild(true)
.correctLengthAtBuild(true)
.build()
outStream.write(ip.rawData)
}
}
No catch-all route ⇒ no packet loop. We don’t call addRoute("0.0.0.0", 0)
, so only DNS lands in the TUN.
Public resolver (1.1.1.1) is routable on every network. Carrier-private resolvers live behind NAT you can’t reach from the TUN.
NXDOMAIN instead of empty A-record. Browsers treat rcode=3
as “host doesn’t exist” and give up immediately instead of retrying IPv6 or DoH.
MTU 1280 keeps us under the typical 1350-byte cellular path-MTU (bye-bye mysterious hangs).
Keep a ConcurrentHashMap<InetAddress, Long>
of “known good” addresses (expires at TTL).
After you forward an allowed DNS answer, add every A/AAAA to the map.
Add addRoute("0.0.0.0", 0)
/ addRoute("::", 0)
and implement a proper forwarder:
UDP: create a DatagramChannel
, copy both directions.
TCP: socket-pair with SocketChannel
+ Selector
.
Drop any packet whose dstAddr !in allowedIps
.
That’s basically what tun2socks
, Intra
, and Nebula
do internally. If you don’t want to maintain your own NAT table, embed something like go-tun2socks
with JNI.
When you do block IPv6 queries, respond with an AAAA
that points to loopback:
dnsMsg.addRecord(
AAAARecord(
dnsMsg.question.name,
dnsMsg.question.dClass,
10,
Inet6Address.getByName("::1")
), Section.ANSWER
)
Chrome will happily move on to the next host in the alt-svc list.
DatagramSocket
– avoids lock contention in your executor.private val dnsSock = ThreadLocal.withInitial {
DatagramSocket().apply { vpn.protect(this) }
}
Timeouts everywhere – missing one receive()
call on cellular was what froze your first run.
Verbose logging for a day, then drop to WARN – battery thanks you.
Happy hacking!