Created
August 5, 2025 12:05
-
-
Save stdStudent/d26909ed8ece7f28ebdef5d3639c455d to your computer and use it in GitHub Desktop.
Get public IP address via DNS (Android, Kotlin)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* License: MPL 2.0 | |
* Text: https://www.mozilla.org/en-US/MPL/2.0/ | |
*/ | |
import android.util.Log | |
import kotlinx.coroutines.Dispatchers | |
import kotlinx.coroutines.withContext | |
import java.net.DatagramPacket | |
import java.net.DatagramSocket | |
import java.net.InetAddress | |
import java.nio.ByteBuffer | |
import java.nio.charset.StandardCharsets | |
class PublicIp { | |
companion object { | |
const val TAG = "PublicIp" | |
} | |
private fun isValidIp(ip: String): Boolean { | |
val parts = ip.split(".") | |
if (parts.size != 4) | |
return false | |
return parts.all { part -> | |
try { | |
val num = part.toInt() | |
num in 0..255 | |
} catch (e: NumberFormatException) { | |
Log.w(TAG, "Invalid IP part: $part", e) | |
false | |
} catch (e: Throwable) { | |
Log.w(TAG, "Unexpected error validating IP part: $part", e) | |
false | |
} | |
} | |
} | |
private fun isValidPublicIp(ip: String): Boolean { | |
if (isValidIp(ip).not()) | |
return false | |
val parts = ip.split(".").map { it.toInt() } | |
val first = parts[0] | |
val second = parts[1] | |
// Filter out private/reserved IP ranges and Google server ranges | |
return when { | |
first == 10 -> false // 10.0.0.0/8 | |
first == 172 && second in 16..31 -> false // 172.16.0.0/12 | |
first == 192 && second == 168 -> false // 192.168.0.0/16 | |
first == 127 -> false // 127.0.0.0/8 (localhost) | |
first == 169 && second == 254 -> false // 169.254.0.0/16 (link-local) | |
first in 224..255 -> false // Multicast and reserved | |
first == 0 -> false // 0.0.0.0/8 | |
// Filter out common Google server IP ranges | |
first == 74 && second == 125 -> false | |
first == 216 && second == 239 -> false | |
first == 172 && second == 217 -> false | |
ip.endsWith(".0") -> false // Network addresses | |
ip.endsWith(".255") -> false // Broadcast addresses | |
else -> true | |
} | |
} | |
private suspend fun resolveNameServer(hostname: String): String? = | |
withContext(Dispatchers.IO) { | |
if (hostname.isEmpty()) { | |
Log.w(TAG, "Hostname is empty") | |
return@withContext null | |
} | |
try { | |
// Use system DNS to resolve the name server IP | |
val address = InetAddress.getByName(hostname) | |
Log.d(TAG, "Resolved $hostname to ${address.hostAddress}") | |
return@withContext address.hostAddress | |
} catch (e: Throwable) { | |
Log.w(TAG, "Failed to resolve $hostname", e) | |
// Hardcoded fallback IPs for Google's name servers | |
val fallbackIps = mapOf( | |
"ns1.google.com" to "216.239.32.10", | |
"ns2.google.com" to "216.239.34.10", | |
"ns3.google.com" to "216.239.36.10", | |
"ns4.google.com" to "216.239.38.10", | |
) | |
Log.d(TAG, "Using fallback IPs for $hostname: ${fallbackIps[hostname]}") | |
return@withContext fallbackIps[hostname] | |
} | |
} | |
private fun extractClientIpFromRecord(record: String): String? { | |
// Skip EDNS subnet records | |
if (record.contains("edns0-client-subnet") || record.contains("/")) | |
return null | |
// Skip Google server IPs (they typically start with certain ranges) | |
if (record.matches(Regex("^74\\.125\\..*")) || | |
record.matches(Regex("^216\\.239\\..*")) || | |
record.matches(Regex("^172\\.217\\..*"))) { | |
Log.d(TAG, "Skipping Google server IP: $record") | |
return null | |
} | |
// Extract and validate IP | |
val ipPattern = Regex("^([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})$") | |
val match = ipPattern.find(record.trim()) | |
return match?.groupValues?.get(1) | |
} | |
private fun createDnsTxtQuery(hostname: String): ByteArray { | |
val buffer = ByteBuffer.allocate(512) | |
// DNS Header | |
buffer.putShort((Math.random() * 65535).toInt().toShort()) // Random transaction ID | |
buffer.putShort(0x0100.toShort()) // Standard query, recursion desired | |
buffer.putShort(1) // Questions | |
buffer.putShort(0) // Answers | |
buffer.putShort(0) // Authority | |
buffer.putShort(0) // Additional | |
// Question section | |
val parts = hostname.split(".") | |
for (part in parts) { | |
buffer.put(part.length.toByte()) | |
buffer.put(part.toByteArray(StandardCharsets.UTF_8)) | |
} | |
buffer.put(0) // End of name | |
buffer.putShort(16) // TXT record type | |
buffer.putShort(1) // Class IN | |
val result = ByteArray(buffer.position()) | |
buffer.rewind() | |
buffer.get(result) | |
return result | |
} | |
private fun createDnsAQuery(hostname: String): ByteArray { | |
val buffer = ByteBuffer.allocate(512) | |
// DNS Header | |
buffer.putShort((Math.random() * 65535).toInt().toShort()) | |
buffer.putShort(0x0100.toShort()) | |
buffer.putShort(1) // Questions | |
buffer.putShort(0) // Answers | |
buffer.putShort(0) // Authority | |
buffer.putShort(0) // Additional | |
// Question section | |
val parts = hostname.split(".") | |
for (part in parts) { | |
buffer.put(part.length.toByte()) | |
buffer.put(part.toByteArray(StandardCharsets.UTF_8)) | |
} | |
buffer.put(0) // End of name | |
buffer.putShort(1) // A record type | |
buffer.putShort(1) // Class IN | |
val result = ByteArray(buffer.position()) | |
buffer.rewind() | |
buffer.get(result) | |
return result | |
} | |
private suspend fun queryTxtRecordDirect(hostname: String, dnsServerIp: String): String? = | |
withContext(Dispatchers.IO) { | |
try { | |
Log.d(TAG, "Querying $hostname directly from $dnsServerIp") | |
val resolver = InetAddress.getByName(dnsServerIp) | |
val socket = DatagramSocket() | |
socket.soTimeout = 15000 | |
val query = createDnsTxtQuery(hostname) | |
val packet = DatagramPacket(query, query.size, resolver, 53) | |
socket.send(packet) | |
val buffer = ByteArray(1024) | |
val response = DatagramPacket(buffer, buffer.size) | |
socket.receive(response) | |
socket.close() | |
// Parse response and look for client IP | |
val txtRecords = parseAllTxtRecords(response.data, response.length) | |
Log.d(TAG, "TXT records from $dnsServerIp: $txtRecords") | |
// Look for the actual client IP (not EDNS subnet) | |
for (record in txtRecords) { | |
val ip = extractClientIpFromRecord(record) | |
if (ip != null && isValidPublicIp(ip)) { | |
Log.d(TAG, "Found client IP in record '$record': $ip") | |
return@withContext ip | |
} | |
} | |
null | |
} catch (e: Throwable) { | |
Log.w(TAG, "Failed to query $hostname from $dnsServerIp", e) | |
null | |
} | |
} | |
private suspend fun queryOpenDnsFallback(): String? = withContext(Dispatchers.IO) { | |
try { | |
Log.d(TAG, "Trying OpenDNS fallback") | |
// OpenDNS returns your IP as an A record for myip.opendns.com | |
val openDnsServers = listOf( | |
"208.67.222.222", | |
"208.67.220.220", | |
"208.67.222.220", | |
"208.67.220.222", | |
).shuffled() | |
for (dnsServer in openDnsServers) { | |
try { | |
val result = queryARecord("myip.opendns.com", dnsServer) | |
if (result != null && isValidPublicIp(result)) { | |
Log.i(TAG, "Got IP from OpenDNS: $result") | |
return@withContext result | |
} | |
} catch (e: Throwable) { | |
Log.w(TAG, "OpenDNS server $dnsServer failed", e) | |
} | |
} | |
null | |
} catch (e: Throwable) { | |
Log.w(TAG, "OpenDNS fallback failed", e) | |
null | |
} | |
} | |
private fun queryARecord(hostname: String, dnsServer: String): String? { | |
try { | |
val resolver = InetAddress.getByName(dnsServer) | |
val socket = DatagramSocket() | |
socket.soTimeout = 10000 | |
val query = createDnsAQuery(hostname) | |
val packet = DatagramPacket(query, query.size, resolver, 53) | |
socket.send(packet) | |
val buffer = ByteArray(512) | |
val response = DatagramPacket(buffer, buffer.size) | |
socket.receive(response) | |
socket.close() | |
return parseARecord(response.data, response.length) | |
} catch (e: Throwable) { | |
Log.w(TAG, "A record query failed", e) | |
return null | |
} | |
} | |
private fun skipDnsName(buffer: ByteBuffer) { | |
while (true) { | |
if (buffer.remaining() < 1) | |
break | |
val length = buffer.get().toInt() and 0xFF | |
if (length == 0) | |
break | |
if ((length and 0xC0) == 0xC0) { | |
if (buffer.remaining() >= 1) | |
buffer.get() | |
break | |
} | |
val skipBytes = minOf(length, buffer.remaining()) | |
buffer.position(buffer.position() + skipBytes) | |
} | |
} | |
private fun readTxtRecord(buffer: ByteBuffer, dataLength: Int): String { | |
val startPos = buffer.position() | |
val endPos = startPos + dataLength | |
val result = StringBuilder() | |
while (buffer.position() < endPos && buffer.hasRemaining()) { | |
val txtLength = buffer.get().toInt() and 0xFF | |
if (txtLength > 0 && buffer.remaining() >= txtLength) { | |
val txtData = ByteArray(txtLength) | |
buffer.get(txtData) | |
val txtString = String(txtData, StandardCharsets.UTF_8) | |
if (result.isNotEmpty()) result.append(" ") | |
result.append(txtString) | |
} else if (txtLength == 0) | |
break | |
else | |
break | |
} | |
return result.toString() | |
} | |
private fun parseAllTxtRecords(data: ByteArray, length: Int): List<String> { | |
val records = mutableListOf<String>() | |
try { | |
val buffer = ByteBuffer.wrap(data, 0, length) | |
// Skip header | |
buffer.getShort() // id | |
buffer.getShort() // flags | |
val questionsCount = buffer.getShort() | |
val answersCount = buffer.getShort() | |
buffer.getShort() // authority | |
buffer.getShort() // additional | |
if (answersCount.toInt() == 0) | |
return records | |
// Skip questions | |
for (i in 0 until questionsCount) { | |
skipDnsName(buffer) | |
buffer.getShort() // type | |
buffer.getShort() // class | |
} | |
// Parse answers | |
for (i in 0 until answersCount) { | |
skipDnsName(buffer) | |
val type = buffer.getShort() | |
buffer.getShort() // class | |
buffer.getInt() // ttl | |
val dataLength = buffer.getShort() | |
if (type.toInt() == 16) { // TXT record | |
val txtRecord = readTxtRecord(buffer, dataLength.toInt()) | |
if (txtRecord.isNotEmpty()) | |
records.add(txtRecord) | |
} else | |
buffer.position(buffer.position() + dataLength) | |
} | |
} catch (e: Throwable) { | |
Log.e(TAG, "Failed to parse TXT records", e) | |
} | |
return records | |
} | |
private fun parseARecord(data: ByteArray, length: Int): String? { | |
try { | |
val buffer = ByteBuffer.wrap(data, 0, length) | |
// Skip header | |
buffer.getShort() // id | |
buffer.getShort() // flags | |
val questionsCount = buffer.getShort() | |
val answersCount = buffer.getShort() | |
buffer.getShort() // authority | |
buffer.getShort() // additional | |
if (answersCount.toInt() == 0) | |
return null | |
// Skip questions | |
for (i in 0 until questionsCount) { | |
skipDnsName(buffer) | |
buffer.getShort() // type | |
buffer.getShort() // class | |
} | |
// Parse answers | |
for (i in 0 until answersCount) { | |
skipDnsName(buffer) | |
val type = buffer.getShort() | |
buffer.getShort() // class | |
buffer.getInt() // ttl | |
val dataLength = buffer.getShort() | |
if (type.toInt() == 1 && dataLength.toInt() == 4) { // A record | |
val ip = ByteArray(4) | |
buffer.get(ip) | |
return "${ip[0].toUByte()}.${ip[1].toUByte()}.${ip[2].toUByte()}.${ip[3].toUByte()}" | |
} else | |
buffer.position(buffer.position() + dataLength) | |
} | |
} catch (e: Throwable) { | |
Log.e(TAG, "Failed to parse A record", e) | |
} | |
return null | |
} | |
/** | |
* Get public IP using Google's authoritative name servers directly | |
* This bypasses local/private IP address and gets public/external IP address | |
*/ | |
suspend fun getViaDNS(): String? = withContext(Dispatchers.IO) { | |
// Google's authoritative name servers | |
val googleNameServers = listOf( | |
"ns1.google.com", | |
"ns2.google.com", | |
"ns3.google.com", | |
"ns4.google.com", | |
).shuffled() | |
for (nsHostname in googleNameServers) { | |
try { | |
Log.d(TAG, "Trying Google NS: $nsHostname") | |
// First resolve the name server IP | |
val nsIp = resolveNameServer(nsHostname) | |
if (nsIp != null) { | |
// Query o-o.myaddr.l.google.com directly from this NS | |
val result = queryTxtRecordDirect("o-o.myaddr.l.google.com", nsIp) | |
if (result != null && isValidPublicIp(result)) { | |
Log.i(TAG, "Got valid public IP from $nsHostname: $result") | |
return@withContext result | |
} | |
} | |
} catch (e: Throwable) { | |
Log.w(TAG, "Failed to query via $nsHostname", e) | |
} | |
} | |
// Fallback to OpenDNS method | |
Log.i(TAG, "Google NS failed, trying OpenDNS fallback") | |
return@withContext queryOpenDnsFallback() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment