blob: dcc0b1f07a63fe8737487a520e4037b820ca721a [file]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package android.net
import android.Manifest.permission.MANAGE_TEST_NETWORKS
import android.annotation.TargetApi
import android.app.usage.NetworkStats
import android.app.usage.NetworkStats.Bucket
import android.app.usage.NetworkStats.Bucket.TAG_NONE
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.ConnectivityManager.TYPE_TEST
import android.net.NetworkStatsIntegrationTest.Direction.DOWNLOAD
import android.net.NetworkStatsIntegrationTest.Direction.UPLOAD
import android.net.NetworkTemplate.MATCH_TEST
import android.os.Build
import android.os.Process
import androidx.test.platform.app.InstrumentationRegistry
import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
import com.android.testutils.DevSdkIgnoreRunner
import com.android.testutils.PacketBridge
import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
import com.android.testutils.TestDnsServer
import com.android.testutils.TestHttpServer
import com.android.testutils.TestableNetworkCallback
import com.android.testutils.runAsShell
import fi.iki.elonen.NanoHTTPD
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.net.HttpURLConnection
import java.net.HttpURLConnection.HTTP_OK
import java.net.InetSocketAddress
import java.net.URL
import java.nio.charset.Charset
import kotlin.math.ceil
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import org.junit.After
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
private const val TEST_TAG = 0xF00D
@RunWith(DevSdkIgnoreRunner::class)
@TargetApi(Build.VERSION_CODES.S)
@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
class NetworkStatsIntegrationTest {
private val INTERNAL_V6ADDR =
LinkAddress(InetAddresses.parseNumericAddress("2001:db8::1234"), 64)
private val EXTERNAL_V6ADDR =
LinkAddress(InetAddresses.parseNumericAddress("2001:db8::5678"), 64)
// Remote address, both the client and server will have a hallucination that
// they are talking to this address.
private val REMOTE_V6ADDR =
LinkAddress(InetAddresses.parseNumericAddress("dead:beef::808:808"), 64)
private val REMOTE_V4ADDR =
LinkAddress(InetAddresses.parseNumericAddress("8.8.8.8"), 32)
private val DEFAULT_MTU = 1500
private val DEFAULT_BUFFER_SIZE = 1500 // Any size greater than or equal to mtu
private val CONNECTION_TIMEOUT_MILLIS = 15000
private val TEST_DOWNLOAD_SIZE = 10000L
private val TEST_UPLOAD_SIZE = 20000L
private val HTTP_SERVER_NAME = "test.com"
private val DNS_SERVER_PORT = 53
private val TCP_ACK_SIZE = 72
// Packet overheads that are not part of the actual data transmission, these
// include DNS packets, TCP handshake/termination packets, and HTTP header
// packets. These overheads were gathered from real samples and may not
// be perfectly accurate because of DNS caches and TCP retransmissions, etc.
private val CONSTANT_PACKET_OVERHEAD = 8
// 130 is an observed average.
private val CONSTANT_BYTES_OVERHEAD = 130 * CONSTANT_PACKET_OVERHEAD
private val TOLERANCE = 1.3
// Set up the packet bridge with two IPv6 address only test networks.
private val inst = InstrumentationRegistry.getInstrumentation()
private val context = inst.getContext()
private val packetBridge = runAsShell(MANAGE_TEST_NETWORKS) {
PacketBridge(context, INTERNAL_V6ADDR, EXTERNAL_V6ADDR, REMOTE_V6ADDR.address)
}
private val cm = context.getSystemService(ConnectivityManager::class.java)
// Set up DNS server for testing server and DNS64.
private val fakeDns = TestDnsServer(
packetBridge.externalNetwork, InetSocketAddress(EXTERNAL_V6ADDR.address, DNS_SERVER_PORT)
).apply {
start()
setAnswer(
"ipv4only.arpa",
listOf(IpPrefix(REMOTE_V6ADDR.address, REMOTE_V6ADDR.prefixLength).address)
)
setAnswer(HTTP_SERVER_NAME, listOf(REMOTE_V4ADDR.address))
}
// Start up test http server.
private val httpServer = TestHttpServer(EXTERNAL_V6ADDR.address.hostAddress).apply {
start()
}
@Before
fun setUp() {
assumeTrue(shouldRunTests())
packetBridge.start()
}
// For networkstack tests, it is not guaranteed that the tethering module will be
// updated at the same time. If the tethering module is not new enough, it may not contain
// the necessary abilities to run these tests. For example, The tests depends on test
// network stats being counted, which can only be achieved when they are marked as TYPE_TEST.
// If the tethering module does not support TYPE_TEST stats, then these tests will need
// to be skipped.
fun shouldRunTests() = cm.getNetworkInfo(packetBridge.internalNetwork).type == TYPE_TEST
@After
fun tearDown() {
packetBridge.stop()
fakeDns.stop()
httpServer.stop()
}
private fun waitFor464XlatReady(network: Network): String {
val iface = cm.getLinkProperties(network).interfaceName
// Make a network request to listen to the specific test network.
val nr = NetworkRequest.Builder()
.clearCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_TEST)
.setNetworkSpecifier(TestNetworkSpecifier(iface))
.build()
val testCb = TestableNetworkCallback()
cm.registerNetworkCallback(nr, testCb)
// Wait for the stacked address to be available.
testCb.eventuallyExpect<LinkPropertiesChanged> {
it.lp.stackedLinks?.getOrNull(0)?.linkAddresses?.getOrNull(0) != null
}
return iface
}
private val Network.mtu: Int get() {
val lp = cm.getLinkProperties(this)
val mtuStacked = if (lp.stackedLinks[0]?.mtu != 0) lp.stackedLinks[0].mtu else DEFAULT_MTU
val mtuInterface = if (lp.mtu != 0) lp.mtu else DEFAULT_MTU
return mtuInterface.coerceAtMost(mtuStacked)
}
/**
* Verify data usage download stats with test 464xlat networks.
*
* This test starts two test networks and binds them together, the internal one is for the
* client to make http traffic on the test network, and the external one is for the mocked
* http and dns server to bind to and provide responses.
*
* After Clat setup, the client will use clat v4 address to send packets to the mocked
* server v4 address, which will be translated into a v6 packet by the clat daemon with
* NAT64 prefix learned from the mocked DNS64 response. And send to the interface.
*
* While the packets are being forwarded to the external interface, the servers will see
* the packets originated from the mocked v6 address, and destined to a local v6 address.
*/
@Test
fun test464XlatTcpStats() {
// Wait for 464Xlat to be ready.
val internalInterfaceName = waitFor464XlatReady(packetBridge.internalNetwork)
val mtu = packetBridge.internalNetwork.mtu
val snapshotBeforeTest = StatsSnapshot(context, internalInterfaceName)
// Generate the download traffic.
genHttpTraffic(packetBridge.internalNetwork, uploadSize = 0L, TEST_DOWNLOAD_SIZE)
// In practice, for one way 10k download payload, the download usage is about
// 11222~12880 bytes, with 14~17 packets. And the upload usage is about 1279~1626 bytes
// with 14~17 packets, which is majorly contributed by TCP ACK packets.
val snapshotAfterDownload = StatsSnapshot(context, internalInterfaceName)
val (expectedDownloadLower, expectedDownloadUpper) = getExpectedStatsBounds(
TEST_DOWNLOAD_SIZE,
mtu,
DOWNLOAD
)
assertOnlyNonTaggedStatsIncreases(
snapshotBeforeTest,
snapshotAfterDownload,
expectedDownloadLower,
expectedDownloadUpper
)
// Generate upload traffic with tag to verify tagged data accounting as well.
genHttpTrafficWithTag(
packetBridge.internalNetwork,
TEST_UPLOAD_SIZE,
downloadSize = 0L,
TEST_TAG
)
// Verify upload data usage accounting.
val snapshotAfterUpload = StatsSnapshot(context, internalInterfaceName)
val (expectedUploadLower, expectedUploadUpper) = getExpectedStatsBounds(
TEST_UPLOAD_SIZE,
mtu,
UPLOAD
)
assertAllStatsIncreases(
snapshotAfterDownload,
snapshotAfterUpload,
expectedUploadLower,
expectedUploadUpper
)
}
private enum class Direction {
DOWNLOAD,
UPLOAD
}
private fun getExpectedStatsBounds(
transmittedSize: Long,
mtu: Int,
direction: Direction
): Pair<BareStats, BareStats> {
// This is already an underestimated value since the input doesn't include TCP/IP
// layer overhead.
val txBytesLower = transmittedSize
// Include TCP/IP header overheads and retransmissions in the upper bound.
val txBytesUpper = (transmittedSize * TOLERANCE).toLong()
val txPacketsLower = txBytesLower / mtu + (CONSTANT_PACKET_OVERHEAD / TOLERANCE).toLong()
val estTransmissionPacketsUpper = ceil(txBytesUpper / mtu.toDouble()).toLong()
val txPacketsUpper = estTransmissionPacketsUpper +
(CONSTANT_PACKET_OVERHEAD * TOLERANCE).toLong()
// Assume ACK only sent once for the entire transmission.
val rxPacketsLower = 1L + (CONSTANT_PACKET_OVERHEAD / TOLERANCE).toLong()
// Assume ACK sent for every RX packet.
val rxPacketsUpper = txPacketsUpper
val rxBytesLower = 1L * TCP_ACK_SIZE + (CONSTANT_BYTES_OVERHEAD / TOLERANCE).toLong()
val rxBytesUpper = estTransmissionPacketsUpper * TCP_ACK_SIZE +
(CONSTANT_BYTES_OVERHEAD * TOLERANCE).toLong()
return if (direction == UPLOAD) {
BareStats(rxBytesLower, rxPacketsLower, txBytesLower, txPacketsLower) to
BareStats(rxBytesUpper, rxPacketsUpper, txBytesUpper, txPacketsUpper)
} else {
BareStats(txBytesLower, txPacketsLower, rxBytesLower, rxPacketsLower) to
BareStats(txBytesUpper, txPacketsUpper, rxBytesUpper, rxPacketsUpper)
}
}
private fun genHttpTraffic(network: Network, uploadSize: Long, downloadSize: Long) =
genHttpTrafficWithTag(network, uploadSize, downloadSize, NetworkStats.Bucket.TAG_NONE)
private fun genHttpTrafficWithTag(
network: Network,
uploadSize: Long,
downloadSize: Long,
tag: Int
) {
val path = "/test_upload_download"
val buf = ByteArray(DEFAULT_BUFFER_SIZE)
httpServer.addResponse(
TestHttpServer.Request(path, NanoHTTPD.Method.POST), NanoHTTPD.Response.Status.OK,
content = getRandomString(downloadSize)
)
var httpConnection: HttpURLConnection? = null
try {
TrafficStats.setThreadStatsTag(tag)
val spec = "http://$HTTP_SERVER_NAME:${httpServer.listeningPort}$path"
val url = URL(spec)
httpConnection = network.openConnection(url) as HttpURLConnection
httpConnection.connectTimeout = CONNECTION_TIMEOUT_MILLIS
httpConnection.requestMethod = "POST"
httpConnection.doOutput = true
// Tell the server that the response should not be compressed. Otherwise, the data usage
// accounted will be less than expected.
httpConnection.setRequestProperty("Accept-Encoding", "identity")
// Tell the server that to close connection after this request, this is needed to
// prevent from reusing the same socket that has different tagging requirement.
httpConnection.setRequestProperty("Connection", "close")
// Send http body.
val outputStream = BufferedOutputStream(httpConnection.outputStream)
outputStream.write(getRandomString(uploadSize).toByteArray(Charset.forName("UTF-8")))
outputStream.close()
assertEquals(HTTP_OK, httpConnection.responseCode)
// Receive response from the server.
val inputStream = BufferedInputStream(httpConnection.getInputStream())
var total = 0L
while (true) {
val count = inputStream.read(buf)
if (count == -1) break // End-of-Stream
total += count
}
assertEquals(downloadSize, total)
} finally {
httpConnection?.inputStream?.close()
TrafficStats.clearThreadStatsTag()
}
}
// NetworkStats.Bucket cannot be written. So another class is needed to
// perform arithmetic operations.
data class BareStats(
val rxBytes: Long,
val rxPackets: Long,
val txBytes: Long,
val txPackets: Long
) {
operator fun plus(other: BareStats): BareStats {
return BareStats(
this.rxBytes + other.rxBytes, this.rxPackets + other.rxPackets,
this.txBytes + other.txBytes, this.txPackets + other.txPackets
)
}
operator fun minus(other: BareStats): BareStats {
return BareStats(
this.rxBytes - other.rxBytes, this.rxPackets - other.rxPackets,
this.txBytes - other.txBytes, this.txPackets - other.txPackets
)
}
fun reverse(): BareStats =
BareStats(
rxBytes = txBytes,
rxPackets = txPackets,
txBytes = rxBytes,
txPackets = rxPackets
)
override fun toString(): String {
return "BareStats{rx/txBytes=$rxBytes/$txBytes, rx/txPackets=$rxPackets/$txPackets}"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is BareStats) return false
if (rxBytes != other.rxBytes) return false
if (rxPackets != other.rxPackets) return false
if (txBytes != other.txBytes) return false
if (txPackets != other.txPackets) return false
return true
}
override fun hashCode(): Int {
return (rxBytes * 11 + rxPackets * 13 + txBytes * 17 + txPackets * 19).toInt()
}
companion object {
val EMPTY = BareStats(0L, 0L, 0L, 0L)
}
}
data class StatsSnapshot(val context: Context, val iface: String) {
val statsSummary = getNetworkSummary(iface)
val statsUid = getUidDetail(iface, TAG_NONE)
val taggedSummary = getTaggedNetworkSummary(iface, TEST_TAG)
val taggedUid = getUidDetail(iface, TEST_TAG)
val trafficStatsIface = getTrafficStatsIface(iface)
val trafficStatsUid = getTrafficStatsUid(Process.myUid())
private fun getUidDetail(iface: String, tag: Int): BareStats {
return getNetworkStatsThat(iface, tag) { nsm, template ->
nsm.queryDetailsForUidTagState(
template, Long.MIN_VALUE, Long.MAX_VALUE,
Process.myUid(), tag, Bucket.STATE_ALL
)
}
}
private fun getNetworkSummary(iface: String): BareStats {
return getNetworkStatsThat(iface, TAG_NONE) { nsm, template ->
nsm.querySummary(template, Long.MIN_VALUE, Long.MAX_VALUE)
}
}
private fun getTaggedNetworkSummary(iface: String, tag: Int): BareStats {
return getNetworkStatsThat(iface, tag) { nsm, template ->
nsm.queryTaggedSummary(template, Long.MIN_VALUE, Long.MAX_VALUE)
}
}
private fun getNetworkStatsThat(
iface: String,
tag: Int,
queryApi: (nsm: NetworkStatsManager, template: NetworkTemplate) -> NetworkStats
): BareStats {
val nsm = context.getSystemService(NetworkStatsManager::class.java)
nsm.forceUpdate()
val testTemplate = NetworkTemplate.Builder(MATCH_TEST)
.setWifiNetworkKeys(setOf(iface)).build()
val stats = queryApi.invoke(nsm, testTemplate)
val filteredBuckets =
stats.buckets().filter { it.uid == Process.myUid() && it.tag == tag }
return filteredBuckets.fold(BareStats.EMPTY) { acc, it ->
acc + BareStats(
it.rxBytes,
it.rxPackets,
it.txBytes,
it.txPackets
)
}
}
// Helper function to iterate buckets in app.usage.NetworkStats.
private fun NetworkStats.buckets() = object : Iterable<NetworkStats.Bucket> {
override fun iterator() = object : Iterator<NetworkStats.Bucket> {
override operator fun hasNext() = hasNextBucket()
override operator fun next() =
NetworkStats.Bucket().also { assertTrue(getNextBucket(it)) }
}
}
private fun getTrafficStatsIface(iface: String): BareStats = BareStats(
TrafficStats.getRxBytes(iface),
TrafficStats.getRxPackets(iface),
TrafficStats.getTxBytes(iface),
TrafficStats.getTxPackets(iface)
)
private fun getTrafficStatsUid(uid: Int): BareStats = BareStats(
TrafficStats.getUidRxBytes(uid),
TrafficStats.getUidRxPackets(uid),
TrafficStats.getUidTxBytes(uid),
TrafficStats.getUidTxPackets(uid)
)
}
private fun assertAllStatsIncreases(
before: StatsSnapshot,
after: StatsSnapshot,
lower: BareStats,
upper: BareStats
) {
assertNonTaggedStatsIncreases(before, after, lower, upper)
assertTaggedStatsIncreases(before, after, lower, upper)
}
private fun assertOnlyNonTaggedStatsIncreases(
before: StatsSnapshot,
after: StatsSnapshot,
lower: BareStats,
upper: BareStats
) {
assertNonTaggedStatsIncreases(before, after, lower, upper)
assertTaggedStatsEquals(before, after)
}
private fun assertNonTaggedStatsIncreases(
before: StatsSnapshot,
after: StatsSnapshot,
lower: BareStats,
upper: BareStats
) {
assertInRange(
"Unexpected iface traffic stats",
after.iface,
before.trafficStatsIface, after.trafficStatsIface,
lower, upper
)
// Uid traffic stats are counted in both direction because the external network
// traffic is also attributed to the test uid.
assertInRange(
"Unexpected uid traffic stats",
after.iface,
before.trafficStatsUid, after.trafficStatsUid,
lower + lower.reverse(), upper + upper.reverse()
)
assertInRange(
"Unexpected non-tagged summary stats",
after.iface,
before.statsSummary, after.statsSummary,
lower, upper
)
assertInRange(
"Unexpected non-tagged uid stats",
after.iface,
before.statsUid, after.statsUid,
lower, upper
)
}
private fun assertTaggedStatsEquals(before: StatsSnapshot, after: StatsSnapshot) {
// Increment of tagged data should be zero since no tagged traffic was generated.
assertEquals(
before.taggedSummary,
after.taggedSummary,
"Unexpected tagged summary stats: ${after.iface}"
)
assertEquals(
before.taggedUid,
after.taggedUid,
"Unexpected tagged uid stats: ${Process.myUid()} on ${after.iface}"
)
}
private fun assertTaggedStatsIncreases(
before: StatsSnapshot,
after: StatsSnapshot,
lower: BareStats,
upper: BareStats
) {
assertInRange(
"Unexpected tagged summary stats",
after.iface,
before.taggedSummary, after.taggedSummary,
lower,
upper
)
assertInRange(
"Unexpected tagged uid stats: ${Process.myUid()}",
after.iface,
before.taggedUid, after.taggedUid,
lower,
upper
)
}
/** Verify the given BareStats is in range [lower, upper] */
private fun assertInRange(
tag: String,
iface: String,
before: BareStats,
after: BareStats,
lower: BareStats,
upper: BareStats
) {
// Passing the value after operation and the value before operation to dump the actual
// numbers if it fails.
val value = after - before
assertTrue(
value.rxBytes in lower.rxBytes..upper.rxBytes &&
value.rxPackets in lower.rxPackets..upper.rxPackets &&
value.txBytes in lower.txBytes..upper.txBytes &&
value.txPackets in lower.txPackets..upper.txPackets,
"$tag on $iface: $after - $before is not within range [$lower, $upper]"
)
}
fun getRandomString(length: Long): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..length)
.map { allowedChars.random() }
.joinToString("")
}
}