79677628

Date: 2025-06-24 13:13:13
Score: 0.5
Natty:
Report link

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.

1. Decide which battle you want to fight

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.

2. DNS filter that actually works on Wi-Fi and LTE

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)
    }
}

3. Hard-blocking at the IP layer (if you really need it)

  1. Keep a ConcurrentHashMap<InetAddress, Long> of “known good” addresses (expires at TTL).

  2. After you forward an allowed DNS answer, add every A/AAAA to the map.

  3. 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.

  4. 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.

4. IPv6 corner-case

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.

5. Quality-of-life tweaks

private val dnsSock = ThreadLocal.withInitial {
    DatagramSocket().apply { vpn.protect(this) }
}
  1. Timeouts everywhere – missing one receive() call on cellular was what froze your first run.

  2. Verbose logging for a day, then drop to WARN – battery thanks you.

Happy hacking!

Reasons:
  • Blacklisted phrase (0.5): thanks
  • Long answer (-1):
  • Has code block (-0.5):
  • Contains question mark (0.5):
  • Low reputation (1):
Posted by: Takashi Doyle