app: nat.stun_5780 — NAT mapping/filtering discovery, verified vs live server
Hand-rolled RFC 5389/5780 STUN client (stdlib only) that exercises the server's stun-5780 capability: one socket, three binding requests (primary, OTHER-ADDRESS alternate IP, CHANGE-REQUEST port) — the comparison classifies NAT mapping and filtering behavior. Verified on the OnePlus: local 10.13.102.124 -> mapped 178.191.120.247:53259 (behind_nat true), alternate address answered from the server's second IP, mapping endpoint-independent, filtering address/port-dependent. Finding nat.symmetric (medium) for the P2P-hostile case. Two real bugs found by running it: port preservation was misread as "no NAT" (compare addresses, not ports), and an unbound socket reports the wildcard local address (resolve via a throwaway connected socket). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd9ecf5032
commit
483de5ca54
@@ -0,0 +1,208 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.probe
|
||||
|
||||
import android.content.Context
|
||||
import app.echo_lot.measurement.Test
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestType
|
||||
import app.echo_lot.measurement.Tier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* nat.stun_5780 — discovers this network's NAT behavior with plain RFC 5389/5780 STUN against the
|
||||
* Echolot server (which advertises `stun-5780` when it has ≥2 same-family addresses).
|
||||
*
|
||||
* Three binding requests, and the comparison between them is the measurement:
|
||||
* 1. **primary address, primary port** → the reflexive (public) address and port.
|
||||
* 2. **same socket, alternate address** (the server's OTHER-ADDRESS): if the mapped port is
|
||||
* unchanged, the NAT keeps one mapping regardless of destination → **endpoint-independent
|
||||
* mapping** (good: peer-to-peer works). A different port → address/port-dependent mapping
|
||||
* (symmetric NAT: P2P needs relays).
|
||||
* 3. **CHANGE-REQUEST(change-port)** — asks the server to answer from a different port. A reply
|
||||
* means the NAT/firewall accepts inbound from an endpoint it never sent to →
|
||||
* **endpoint-independent filtering**; silence means address/port-dependent filtering.
|
||||
*
|
||||
* Also detects being behind NAT at all (mapped address ≠ local address) — the CGNAT/double-NAT
|
||||
* signal when combined with the local address being private.
|
||||
*/
|
||||
class StunProbe(
|
||||
private val serverHost: String,
|
||||
private val stunPort: Int = 3478,
|
||||
) : Probe {
|
||||
override val type = TestType.NAT_STUN_5780
|
||||
override val tier = Tier.APP
|
||||
|
||||
private companion object {
|
||||
const val MAGIC_COOKIE = 0x2112A442.toInt()
|
||||
const val TYPE_BINDING_REQUEST = 0x0001
|
||||
const val TYPE_BINDING_SUCCESS = 0x0101
|
||||
const val ATTR_CHANGE_REQUEST = 0x0003
|
||||
const val ATTR_XOR_MAPPED = 0x0020
|
||||
const val ATTR_OTHER_ADDRESS = 0x802C
|
||||
const val CHANGE_PORT = 0x02
|
||||
}
|
||||
|
||||
private data class Mapped(val addr: String, val port: Int)
|
||||
private data class Reply(val mapped: Mapped?, val other: Mapped?)
|
||||
|
||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||
val b = TestBuilder(type, tier, ids)
|
||||
DatagramSocket().use { sock ->
|
||||
sock.soTimeout = 3000
|
||||
val localPort = sock.localPort
|
||||
|
||||
// 1) primary
|
||||
val r1 = request(sock, serverHost, stunPort, change = 0)
|
||||
if (r1?.mapped == null) {
|
||||
return@withContext b.build(
|
||||
TestStatus.FAILED,
|
||||
evidence = buildJsonObject {
|
||||
put("server", "$serverHost:$stunPort")
|
||||
put("error", "no STUN binding response (blocked or server unreachable)")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 2) same socket → the server's alternate address (OTHER-ADDRESS)
|
||||
val alt = r1.other
|
||||
val r2 = alt?.let { request(sock, it.addr, stunPort, change = 0) }
|
||||
|
||||
// 3) CHANGE-REQUEST(port) — tests inbound filtering
|
||||
val r3 = request(sock, serverHost, stunPort, change = CHANGE_PORT)
|
||||
|
||||
val mappingBehavior = when {
|
||||
alt == null -> "unknown (server has no OTHER-ADDRESS; only stun-basic)"
|
||||
r2?.mapped == null -> "inconclusive (no reply from alternate address)"
|
||||
r2.mapped.port == r1.mapped.port && r2.mapped.addr == r1.mapped.addr ->
|
||||
"endpoint-independent (one mapping for all destinations — P2P friendly)"
|
||||
else -> "address/port-dependent (symmetric NAT — P2P needs a relay)"
|
||||
}
|
||||
val filteringBehavior = when {
|
||||
r3?.mapped != null -> "endpoint-independent (accepts inbound from an unseen endpoint)"
|
||||
else -> "address/port-dependent (drops inbound from endpoints not contacted)"
|
||||
}
|
||||
// Behind NAT iff the reflexive address differs from this socket's local address.
|
||||
// Port preservation is common and must NOT be read as "no NAT" — compare addresses.
|
||||
val localAddr = localAddressOf(sock)
|
||||
val behindNat = localAddr.isNotEmpty() && localAddr != r1.mapped.addr
|
||||
|
||||
val evidence: JsonObject = buildJsonObject {
|
||||
put("server", "$serverHost:$stunPort")
|
||||
put("local_addr", localAddr)
|
||||
put("local_port", localPort)
|
||||
put("mapped", "${r1.mapped.addr}:${r1.mapped.port}")
|
||||
put("other_address", alt?.let { "${it.addr}:${it.port}" } ?: "")
|
||||
put("mapped_via_alt", r2?.mapped?.let { "${it.addr}:${it.port}" } ?: "(no reply)")
|
||||
put("change_port_reply", if (r3?.mapped != null) "received" else "none")
|
||||
put("mapping_behavior", mappingBehavior)
|
||||
put("filtering_behavior", filteringBehavior)
|
||||
put("behind_nat", behindNat)
|
||||
}
|
||||
val metrics = buildJsonObject {
|
||||
put("mapped_port", r1.mapped.port)
|
||||
put("local_addr", localAddr)
|
||||
put("local_port", localPort)
|
||||
put("port_preserved", r1.mapped.port == localPort)
|
||||
put("behind_nat", behindNat)
|
||||
}
|
||||
b.build(TestStatus.OK, evidence = evidence, metrics = metrics)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The address this socket actually sources from. An unbound DatagramSocket reports the
|
||||
* wildcard ("::"/"0.0.0.0"), which says nothing, so probe the route to the server with a
|
||||
* throwaway connected socket and read its local address.
|
||||
*/
|
||||
private fun localAddressOf(sock: DatagramSocket): String {
|
||||
val direct = sock.localAddress?.hostAddress ?: ""
|
||||
if (direct.isNotEmpty() && direct != "::" && direct != "0.0.0.0") return direct
|
||||
return runCatching {
|
||||
DatagramSocket().use { s ->
|
||||
s.connect(InetSocketAddress(serverHost, stunPort))
|
||||
s.localAddress?.hostAddress ?: ""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
/** One binding request; returns the parsed reply or null on timeout. */
|
||||
private fun request(sock: DatagramSocket, host: String, port: Int, change: Int): Reply? {
|
||||
val txid = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
val attrs = if (change != 0) {
|
||||
ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).apply {
|
||||
putShort(ATTR_CHANGE_REQUEST.toShort()); putShort(4)
|
||||
put(0); put(0); put(0); put(change.toByte())
|
||||
}.array()
|
||||
} else ByteArray(0)
|
||||
|
||||
val msg = ByteBuffer.allocate(20 + attrs.size).order(ByteOrder.BIG_ENDIAN)
|
||||
msg.putShort(TYPE_BINDING_REQUEST.toShort())
|
||||
msg.putShort(attrs.size.toShort())
|
||||
msg.putInt(MAGIC_COOKIE)
|
||||
msg.put(txid)
|
||||
msg.put(attrs)
|
||||
val bytes = msg.array()
|
||||
|
||||
return try {
|
||||
sock.send(DatagramPacket(bytes, bytes.size, InetSocketAddress(host, port)))
|
||||
val buf = ByteArray(1500)
|
||||
val dp = DatagramPacket(buf, buf.size)
|
||||
sock.receive(dp)
|
||||
parse(buf, dp.length, txid)
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(data: ByteArray, len: Int, txid: ByteArray): Reply? {
|
||||
if (len < 20) return null
|
||||
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
|
||||
if ((bb.getShort(0).toInt() and 0xFFFF) != TYPE_BINDING_SUCCESS) return null
|
||||
val msgLen = bb.getShort(2).toInt() and 0xFFFF
|
||||
var mapped: Mapped? = null
|
||||
var other: Mapped? = null
|
||||
var off = 20
|
||||
while (off + 4 <= 20 + msgLen && off + 4 <= len) {
|
||||
val at = bb.getShort(off).toInt() and 0xFFFF
|
||||
val al = bb.getShort(off + 2).toInt() and 0xFFFF
|
||||
if (off + 4 + al > len) break
|
||||
when (at) {
|
||||
ATTR_XOR_MAPPED -> mapped = parseAddr(data, off + 4, al, txid, xor = true)
|
||||
ATTR_OTHER_ADDRESS -> other = parseAddr(data, off + 4, al, txid, xor = false)
|
||||
}
|
||||
off += 4 + al + ((4 - al % 4) % 4)
|
||||
}
|
||||
return Reply(mapped, other)
|
||||
}
|
||||
|
||||
/** RFC 5389 address attribute; XOR-MAPPED needs de-XORing with the cookie + txid. */
|
||||
private fun parseAddr(data: ByteArray, off: Int, len: Int, txid: ByteArray, xor: Boolean): Mapped? {
|
||||
if (len < 8) return null
|
||||
val v = data.copyOfRange(off, off + len)
|
||||
val family = v[1].toInt() and 0xFF
|
||||
var port = ((v[2].toInt() and 0xFF) shl 8) or (v[3].toInt() and 0xFF)
|
||||
if (xor) port = port xor ((MAGIC_COOKIE ushr 16) and 0xFFFF)
|
||||
val key = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN)
|
||||
.putInt(MAGIC_COOKIE).put(txid).array()
|
||||
return if (family == 0x01) {
|
||||
val a = ByteArray(4) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
|
||||
Mapped(a.joinToString(".") { (it.toInt() and 0xFF).toString() }, port)
|
||||
} else {
|
||||
if (len < 20) return null
|
||||
val a = ByteArray(16) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
|
||||
Mapped(java.net.InetAddress.getByAddress(a).hostAddress ?: "", port)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user