Add L2capPacketForwarder
L2capPacketForwarder forwards packets between an L2CAP BluetoothSocket
and a tun fd.
It uses blocking i/o to do so, since the BluetoothSocket API does not
expose a file descriptor to poll() on.
It remains to be seen whether shutdown() on a tun interface fd is
actually implemented. Currently, the test uses a pair of SOCK_SEQPACKET
sockets which require to be shutdown to interrupt the blocking read.
Test: L2capPacketForwarderTest
Change-Id: I03d2a2dd76ec2b71fdb9e3ea7008daa6d1d124fe
diff --git a/service/Android.bp b/service/Android.bp
index c4e2ef0..8b469e4 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -161,6 +161,7 @@
],
libs: [
"framework-annotations-lib",
+ "framework-bluetooth.stubs.module_lib",
"framework-configinfrastructure.stubs.module_lib",
"framework-connectivity-pre-jarjar",
// The framework-connectivity-t library is only available on T+ platforms
diff --git a/service/src/com/android/server/net/L2capPacketForwarder.java b/service/src/com/android/server/net/L2capPacketForwarder.java
new file mode 100644
index 0000000..cef351c
--- /dev/null
+++ b/service/src/com/android/server/net/L2capPacketForwarder.java
@@ -0,0 +1,322 @@
+/*
+ * Copyright (C) 2025 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 com.android.server.net;
+
+import android.bluetooth.BluetoothSocket;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Forwards packets from a BluetoothSocket of type L2CAP to a tun fd and vice versa.
+ *
+ * The forwarding logic operates on raw IP packets and there are no ethernet headers.
+ * Therefore, L3 MTU = L2 MTU.
+ */
+public class L2capPacketForwarder {
+ private static final String TAG = "L2capPacketForwarder";
+
+ // DCT specifies an MTU of 1500.
+ // TODO: Set /proc/sys/net/ipv6/conf/${iface}/mtu to 1280 and the link MTU to 1528 to accept
+ // slightly larger packets on ingress (i.e. packets passing through a NAT64 gateway).
+ // MTU determines the value of the read buffers, so use the larger of the two.
+ @VisibleForTesting
+ public static final int MTU = 1528;
+ private final Handler mHandler;
+ private final IReadWriteFd mTunFd;
+ private final IReadWriteFd mL2capFd;
+ private final L2capThread mIngressThread;
+ private final L2capThread mEgressThread;
+ private final ICallback mCallback;
+
+ public interface ICallback {
+ /** Called when an error is encountered; should tear down forwarding. */
+ void onError();
+ }
+
+ private interface IReadWriteFd {
+ /**
+ * Read up to len bytes into bytes[off] and return bytes actually read.
+ *
+ * bytes[] must be of size >= off + len.
+ */
+ int read(byte[] bytes, int off, int len) throws IOException;
+ /**
+ * Write len bytes starting from bytes[off]
+ *
+ * bytes[] must be of size >= off + len.
+ */
+ void write(byte[] bytes, int off, int len) throws IOException;
+ /** Disallow further receptions, shutdown(fd, SHUT_RD) */
+ void shutdownRead();
+ /** Disallow further transmissions, shutdown(fd, SHUT_WR) */
+ void shutdownWrite();
+ /** Close the fd */
+ void close();
+ }
+
+ @VisibleForTesting
+ public static class BluetoothSocketWrapper implements IReadWriteFd {
+ private final BluetoothSocket mSocket;
+ private final InputStream mInputStream;
+ private final OutputStream mOutputStream;
+
+ public BluetoothSocketWrapper(BluetoothSocket socket) {
+ // TODO: assert that MTU can fit within Bluetooth L2CAP SDU (maximum size of an L2CAP
+ // packet). The L2CAP SDU is 65535 by default, but can be less when using hardware
+ // offload.
+ mSocket = socket;
+ try {
+ mInputStream = socket.getInputStream();
+ mOutputStream = socket.getOutputStream();
+ } catch (IOException e) {
+ // Per the API docs, this should not actually be possible.
+ Log.wtf(TAG, "Failed to get Input/OutputStream", e);
+ // Fail hard.
+ throw new IllegalStateException("Failed to get Input/OutputStream");
+ }
+ }
+
+ /** Read from the BluetoothSocket. */
+ @Override
+ public int read(byte[] bytes, int off, int len) throws IOException {
+ // Note: EINTR is handled internally and automatically triggers a retry loop.
+ int bytesRead = mInputStream.read(bytes, off, len);
+ if (bytesRead > MTU) {
+ // Don't try to recover, just trigger network teardown. This might indicate a bug in
+ // the Bluetooth stack.
+ throw new IOException("Packet exceeds MTU");
+ }
+ return bytesRead;
+ }
+
+ /** Write to the BluetoothSocket. */
+ @Override
+ public void write(byte[] bytes, int off, int len) throws IOException {
+ // Note: EINTR is handled internally and automatically triggers a retry loop.
+ mOutputStream.write(bytes, off, len);
+ }
+
+ @Override
+ public void shutdownRead() {
+ // BluetoothSocket does not expose methods to shutdown read / write individually;
+ // however, BluetoothSocket#close() shuts down both read and write and is safe to be
+ // called multiple times from any thread.
+ try {
+ mSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, "shutdownRead: Failed to close BluetoothSocket", e);
+ }
+ }
+
+ @Override
+ public void shutdownWrite() {
+ // BluetoothSocket does not expose methods to shutdown read / write individually;
+ // however, BluetoothSocket#close() shuts down both read and write and is safe to be
+ // called multiple times from any thread.
+ try {
+ mSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, "shutdownWrite: Failed to close BluetoothSocket", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ // BluetoothSocket#close() is safe to be called multiple times.
+ try {
+ mSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, "close: Failed to close BluetoothSocket", e);
+ }
+ }
+ }
+
+ @VisibleForTesting
+ public static class FdWrapper implements IReadWriteFd {
+ private final ParcelFileDescriptor mFd;
+
+ public FdWrapper(ParcelFileDescriptor fd) {
+ mFd = fd;
+ }
+
+ @Override
+ public int read(byte[] bytes, int off, int len) throws IOException {
+ try {
+ // Note: EINTR is handled internally and automatically triggers a retry loop.
+ return Os.read(mFd.getFileDescriptor(), bytes, off, len);
+ } catch (ErrnoException e) {
+ throw new IOException(e);
+ }
+ }
+
+ /**
+ * Write to BluetoothSocket.
+ */
+ @Override
+ public void write(byte[] bytes, int off, int len) throws IOException {
+ try {
+ // Note: EINTR is handled internally and automatically triggers a retry loop.
+ Os.write(mFd.getFileDescriptor(), bytes, off, len);
+ } catch (ErrnoException e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void shutdownRead() {
+ try {
+ Os.shutdown(mFd.getFileDescriptor(), OsConstants.SHUT_RD);
+ } catch (ErrnoException e) {
+ Log.w(TAG, "shutdownRead: Failed to shutdown(fd, SHUT_RD)", e);
+ }
+ }
+
+ @Override
+ public void shutdownWrite() {
+ try {
+ Os.shutdown(mFd.getFileDescriptor(), OsConstants.SHUT_WR);
+ } catch (ErrnoException e) {
+ Log.w(TAG, "shutdownWrite: Failed to shutdown(fd, SHUT_WR)", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ // Safe to call multiple times. Both Os.close(FileDescriptor) and
+ // ParcelFileDescriptor#close() offer protection against double-closing an fd.
+ mFd.close();
+ } catch (IOException e) {
+ Log.w(TAG, "close: Failed to close fd", e);
+ }
+ }
+ }
+
+ private class L2capThread extends Thread {
+ // Set mBuffer length to MTU + 1 to catch read() overflows.
+ private final byte[] mBuffer = new byte[MTU + 1];
+ private volatile boolean mIsRunning = true;
+
+ private final String mLogTag;
+ private IReadWriteFd mReadFd;
+ private IReadWriteFd mWriteFd;
+
+ L2capThread(String logTag, IReadWriteFd readFd, IReadWriteFd writeFd) {
+ mLogTag = logTag;
+ mReadFd = readFd;
+ mWriteFd = writeFd;
+ }
+
+ private void postOnError() {
+ mHandler.post(() -> {
+ // All callbacks must be called on handler thread.
+ mCallback.onError();
+ });
+ }
+
+ @Override
+ public void run() {
+ while (mIsRunning) {
+ try {
+ final int readBytes = mReadFd.read(mBuffer, 0 /*off*/, mBuffer.length);
+ // No bytes to write, continue.
+ if (readBytes <= 0) {
+ Log.w(mLogTag, "Zero-byte read encountered: " + readBytes);
+ continue;
+ }
+
+ // If the packet exceeds MTU, drop it.
+ // Note that a large read on BluetoothSocket throws an IOException to tear down
+ // the network.
+ if (readBytes > MTU) continue;
+
+ mWriteFd.write(mBuffer, 0 /*off*/, readBytes);
+ } catch (IOException e) {
+ Log.e(mLogTag, "L2capThread exception", e);
+ // Tear down the network on any error.
+ mIsRunning = false;
+ // Notify provider that forwarding has stopped.
+ postOnError();
+ }
+ }
+ }
+
+ public void tearDown() {
+ mIsRunning = false;
+ mReadFd.shutdownRead();
+ mWriteFd.shutdownWrite();
+ }
+ }
+
+ public L2capPacketForwarder(Handler handler, ParcelFileDescriptor tunFd, BluetoothSocket socket,
+ ICallback cb) {
+ this(handler, new FdWrapper(tunFd), new BluetoothSocketWrapper(socket), cb);
+ }
+
+ @VisibleForTesting
+ L2capPacketForwarder(Handler handler, IReadWriteFd tunFd, IReadWriteFd l2capFd, ICallback cb) {
+ mHandler = handler;
+ mTunFd = tunFd;
+ mL2capFd = l2capFd;
+ mCallback = cb;
+
+ mIngressThread = new L2capThread("L2capThread-Ingress", l2capFd, tunFd);
+ mEgressThread = new L2capThread("L2capThread-Egress", tunFd, l2capFd);
+
+ mIngressThread.start();
+ mEgressThread.start();
+ }
+
+ /**
+ * Tear down the L2capPacketForwarder.
+ *
+ * This operation closes both the passed tun fd and BluetoothSocket.
+ **/
+ public void tearDown() {
+ // Destroying both threads first guarantees that both read and write side of FD have been
+ // shutdown.
+ mIngressThread.tearDown();
+ mEgressThread.tearDown();
+
+ // In order to interrupt a blocking read on the BluetoothSocket, the BluetoothSocket must be
+ // closed (which triggers shutdown()). This means, the BluetoothSocket must be closed inside
+ // L2capPacketForwarder. Tear down the tun fd alongside it for consistency.
+ mTunFd.close();
+ mL2capFd.close();
+
+ try {
+ mIngressThread.join();
+ } catch (InterruptedException e) {
+ // join() interrupted in tearDown path, do nothing.
+ }
+ try {
+ mEgressThread.join();
+ } catch (InterruptedException e) {
+ // join() interrupted in tearDown path, do nothing.
+ }
+ }
+}
diff --git a/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt b/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt
new file mode 100644
index 0000000..b3095ee
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2025 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 com.android.server.net
+
+import android.bluetooth.BluetoothSocket
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.ParcelFileDescriptor
+import android.system.Os
+import android.system.OsConstants.AF_UNIX
+import android.system.OsConstants.SHUT_RD
+import android.system.OsConstants.SHUT_WR
+import android.system.OsConstants.SOCK_SEQPACKET
+import android.system.OsConstants.SOL_SOCKET
+import android.system.OsConstants.SO_RCVTIMEO
+import android.system.OsConstants.SO_SNDTIMEO
+import android.system.StructTimeval
+import com.android.server.net.L2capPacketForwarder.BluetoothSocketWrapper
+import com.android.server.net.L2capPacketForwarder.FdWrapper
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.google.common.truth.Truth.assertThat
+import java.io.FileDescriptor
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.ByteBuffer
+import kotlin.arrayOf
+import kotlin.random.Random
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+private const val TIMEOUT = 1000L
+
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class L2capPacketForwarderTest {
+ private lateinit var forwarder: L2capPacketForwarder
+ private val tunFds = arrayOf(FileDescriptor(), FileDescriptor())
+ private val l2capFds = arrayOf(FileDescriptor(), FileDescriptor())
+ private lateinit var l2capInputStream: BluetoothL2capInputStream
+ private lateinit var l2capOutputStream: BluetoothL2capOutputStream
+ @Mock private lateinit var bluetoothSocket: BluetoothSocket
+ @Mock private lateinit var callback: L2capPacketForwarder.ICallback
+
+ private val handlerThread = HandlerThread("L2capPacketForwarderTest thread").apply { start() }
+ private val handler = Handler(handlerThread.looper)
+
+ /** Imitates the behavior of an L2CAP BluetoothSocket */
+ private class BluetoothL2capInputStream(
+ val fd: FileDescriptor,
+ ) : InputStream() {
+ val l2capBuffer = ByteBuffer.wrap(ByteArray(0xffff)).apply {
+ limit(0)
+ }
+
+ override fun read(): Int {
+ throw NotImplementedError("b/391623333: not implemented correctly for L2cap sockets")
+ }
+
+ /** See BluetoothSocket#read(buf, off, len) */
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
+ // If no more bytes are remaining, read from the fd into the intermediate buffer.
+ if (l2capBuffer.remaining() == 0) {
+ // fillL2capRxBuffer()
+ // refill buffer and return - 1
+ val backingArray = l2capBuffer.array()
+ var bytesRead = 0
+ try {
+ bytesRead = Os.read(fd, backingArray, 0 /*off*/, backingArray.size)
+ } catch (e: Exception) {
+ // read failed, timed out, or was interrupted
+ // InputStream throws IOException
+ throw IOException(e)
+ }
+ l2capBuffer.rewind()
+ l2capBuffer.limit(bytesRead)
+ }
+
+ val bytesToRead = if (len > l2capBuffer.remaining()) l2capBuffer.remaining() else len
+ l2capBuffer.get(b, off, bytesToRead)
+ return bytesToRead
+ }
+
+ override fun available(): Int {
+ throw NotImplementedError("b/391623333: not implemented correctly for L2cap sockets")
+ }
+
+ override fun close() {
+ try {
+ Os.shutdown(fd, SHUT_RD)
+ } catch (e: Exception) {
+ // InputStream throws IOException
+ throw IOException(e)
+ }
+ }
+ }
+
+ /** Imitates the behavior of an L2CAP BluetoothSocket */
+ private class BluetoothL2capOutputStream(
+ val fd: FileDescriptor,
+ ) : OutputStream() {
+
+ override fun write(b: Int) {
+ throw NotImplementedError("This method does not maintain packet boundaries, do not use")
+ }
+
+ /** See BluetoothSocket#write(buf, off, len) */
+ override fun write(b: ByteArray, off: Int, len: Int) {
+ try {
+ Os.write(fd, b, off, len)
+ } catch (e: Exception) {
+ // OutputStream throws IOException
+ throw IOException(e)
+ }
+ }
+
+ override fun close() {
+ try {
+ Os.shutdown(fd, SHUT_WR)
+ } catch (e: Exception) {
+ // OutputStream throws IOException
+ throw IOException(e)
+ }
+ }
+ }
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.initMocks(this)
+
+ Os.socketpair(AF_UNIX, SOCK_SEQPACKET, 0, tunFds[0], tunFds[1])
+ Os.socketpair(AF_UNIX, SOCK_SEQPACKET, 0, l2capFds[0], l2capFds[1])
+
+ // Set socket i/o timeout for test end.
+ Os.setsockoptTimeval(tunFds[1], SOL_SOCKET, SO_RCVTIMEO, StructTimeval.fromMillis(5000))
+ Os.setsockoptTimeval(tunFds[1], SOL_SOCKET, SO_SNDTIMEO, StructTimeval.fromMillis(5000))
+ Os.setsockoptTimeval(l2capFds[1], SOL_SOCKET, SO_RCVTIMEO, StructTimeval.fromMillis(5000))
+ Os.setsockoptTimeval(l2capFds[1], SOL_SOCKET, SO_SNDTIMEO, StructTimeval.fromMillis(5000))
+
+ l2capInputStream = BluetoothL2capInputStream(l2capFds[0])
+ l2capOutputStream = BluetoothL2capOutputStream(l2capFds[0])
+ doReturn(l2capInputStream).`when`(bluetoothSocket).getInputStream()
+ doReturn(l2capOutputStream).`when`(bluetoothSocket).getOutputStream()
+ doAnswer({
+ l2capInputStream.close()
+ l2capOutputStream.close()
+ try {
+ // libcore's Linux_close properly invalidates the FileDescriptor, so it is safe to
+ // close multiple times.
+ Os.close(l2capFds[0])
+ } catch (e: Exception) {
+ // BluetoothSocket#close can be called multiple times. Catch EBADF on subsequent
+ // invocations.
+ }
+ }).`when`(bluetoothSocket).close()
+
+ forwarder = L2capPacketForwarder(
+ handler,
+ FdWrapper(ParcelFileDescriptor(tunFds[0])),
+ BluetoothSocketWrapper(bluetoothSocket),
+ callback
+ )
+ }
+
+ @After
+ fun tearDown() {
+ if (::forwarder.isInitialized) {
+ // forwarder closes tunFds[0] and l2capFds[0]
+ forwarder.tearDown()
+ } else {
+ Os.close(tunFds[0])
+ Os.close(l2capFds[0])
+ }
+ Os.close(tunFds[1])
+ Os.close(l2capFds[1])
+
+ handlerThread.quitSafely()
+ handlerThread.join()
+ }
+
+ fun sendPacket(fd: FileDescriptor, size: Int = 1280): ByteArray {
+ val packet = ByteArray(size)
+ Random.nextBytes(packet)
+ Os.write(fd, packet, 0 /*off*/, packet.size)
+ return packet
+ }
+
+ fun assertPacketReceived(fd: FileDescriptor, expected: ByteArray) {
+ val readBuffer = ByteArray(expected.size)
+ Os.read(fd, readBuffer, 0 /*off*/, readBuffer.size)
+ assertThat(readBuffer).isEqualTo(expected)
+ }
+
+ @Test
+ fun testForwarding_withoutHeaderCompression() {
+ var packet = sendPacket(l2capFds[1])
+ var packet2 = sendPacket(l2capFds[1])
+ assertPacketReceived(tunFds[1], packet)
+ assertPacketReceived(tunFds[1], packet2)
+
+ packet = sendPacket(tunFds[1])
+ packet2 = sendPacket(tunFds[1])
+ assertPacketReceived(l2capFds[1], packet)
+ assertPacketReceived(l2capFds[1], packet2)
+ }
+
+ @Test
+ fun testForwarding_packetExceedsMtu() {
+ // Reading from tun drops packets that exceed MTU.
+ // drop
+ sendPacket(tunFds[1], L2capPacketForwarder.MTU + 1)
+ // drop
+ sendPacket(tunFds[1], L2capPacketForwarder.MTU + 42)
+ var packet = sendPacket(l2capFds[1], 1280)
+ assertPacketReceived(tunFds[1], packet)
+
+ // On the BluetoothSocket side, reads that exceed MTU stop forwarding.
+ sendPacket(l2capFds[1], L2capPacketForwarder.MTU + 1)
+ verify(callback, timeout(TIMEOUT)).onError()
+ }
+}