From 627b8c6d343e2a38869cdbff9c1d09a15716b47a Mon Sep 17 00:00:00 2001 From: Motomu Utsumi Date: Thu, 20 Feb 2025 16:54:12 +0900 Subject: [PATCH] Add hidden API to register/unregister QUIC connection close payload QUIC stack running on the app process will call this API and register QUIC UDP socket and UDP payload that can close QUIC connection. When the app loses network access (e.g. due to freezer or firewall chain), ConnectivityService 1)destroys the registered UDP socket by sending a SOCK_DESTROY netlink message and 2)sends the registered UDP payload to the server. This prevents unnecessary modem wakeups caused by packets from the server after the apps lose network access. Additionally, to close QUIC connection for apps that crash or are killed, ConnectivityService sends the registered UDP payload when it receives a socket destroy diag message for the registered socket. Bug: 311792075 Test: CSDestroySocketTest CtsNetTestCases Change-Id: I6cd727bd8bb0fe1d17360edfa395f73d24da6920 --- .../src/android/net/ConnectivityManager.java | 64 +++ .../src/android/net/IConnectivityManager.aidl | 3 + .../android/server/ConnectivityService.java | 60 +++ .../connectivity/ConnectivityFlags.java | 2 + .../connectivity/QuicConnectionCloser.java | 406 ++++++++++++++++++ .../net/cts/ConnectivityManagerTest.java | 328 +++++++++++++- .../server/ConnectivityServiceTest.java | 1 + .../connectivity/QuicConnectionCloserTest.kt | 232 ++++++++++ .../CSDestroySocketTest.kt | 11 +- .../server/connectivityservice/base/CSTest.kt | 10 + 10 files changed, 1110 insertions(+), 7 deletions(-) create mode 100644 service/src/com/android/server/connectivity/QuicConnectionCloser.java create mode 100644 tests/unit/java/com/android/server/connectivity/QuicConnectionCloserTest.kt diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java index f8a1293a34..3a7d070670 100644 --- a/framework/src/android/net/ConnectivityManager.java +++ b/framework/src/android/net/ConnectivityManager.java @@ -6856,4 +6856,68 @@ public class ConnectivityManager { throw e.rethrowFromSystemServer(); } } + + /** + * Register QUIC UDP socket and UDP payload that can close QUIC connection. + * + * When the app loses network access (e.g. due to freezer or firewall chain), + * ConnectivityService 1)destroys the registered UDP socket by sending a SOCK_DESTROY netlink + * message and 2)sends the registered UDP payload to the server. + * This prevents unnecessary modem wakeups caused by packets from the server after the apps + * lose network access. + * + * Additionally, to close QUIC connection for apps that crash or are killed, ConnectivityService + * sends the registered UDP payload when it receives a socket destroy diag message for the + * registered socket. + * + * If apps call this method, they must call {@link #unregisterQuicConnectionClosePayload} before + * they close the QUIC connection and the socket. + * Otherwise, the registered UDP payload will be sent to the server, even if the QUIC connection + * was already properly closed. This is because ConnectivityService cannot distinguish whether + * the socket was closed due to the app being killed or because the app forgets to call + * {@link #unregisterQuicConnectionClosePayload}. + * + * The registered payload will be sent using the source and destination addresses and ports of + * the connected UDP socket provided. So, the app must not reuse the same socket to reconnect + * to a different server, even if first calls unregisterQuicConnectionClosePayload. This is + * because this method operates asynchronously. + * + * If the number of registered socket and payload hits the limit, ConnectivityService silently + * ignores new registration. ConnectivityService cannot know about this synchronously, but the + * caller couldn't really do anything with this information anyway. + * TODO: Consider adding a result listener if this API becomes public. + * + * @param pfd The {@link ParcelFileDescriptor} for the connected UDP socket. + * The registered payload will be sent using the source and destination addresses and + * ports of this connected UDP socket. + * @param payload The UDP payload that can close QUIC connection. + * + * @hide + */ + public void registerQuicConnectionClosePayload(final ParcelFileDescriptor pfd, + final byte[] payload) { + try { + mService.registerQuicConnectionClosePayload(pfd, payload); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + * Unregister the QUIC socket previously registered by + * {@link #registerQuicConnectionClosePayload} + * + * This method is a no-op if the provided UDP socket is not registered. + * + * @param pfd The {@link ParcelFileDescriptor} for the UDP socket. + * + * @hide + */ + public void unregisterQuicConnectionClosePayload(final ParcelFileDescriptor pfd) { + try { + mService.unregisterQuicConnectionClosePayload(pfd); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } } diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl index a270684c40..0146e729a7 100644 --- a/framework/src/android/net/IConnectivityManager.aidl +++ b/framework/src/android/net/IConnectivityManager.aidl @@ -266,4 +266,7 @@ interface IConnectivityManager long getEnabledConnectivityManagerFeatures(); boolean isConnectivityServiceFeatureEnabledForTesting(String featureFlag); + + oneway void registerQuicConnectionClosePayload(in ParcelFileDescriptor pfd, in byte[] payload); + oneway void unregisterQuicConnectionClosePayload(in ParcelFileDescriptor pfd); } diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java index 45b431f1e7..7f317930a4 100644 --- a/service/src/com/android/server/ConnectivityService.java +++ b/service/src/com/android/server/ConnectivityService.java @@ -156,6 +156,7 @@ import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPer import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf; import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE; import static com.android.server.connectivity.ConnectivityFlags.CELLULAR_DATA_INACTIVITY_TIMEOUT; +import static com.android.server.connectivity.ConnectivityFlags.CLOSE_QUIC_CONNECTION; import static com.android.server.connectivity.ConnectivityFlags.DELAY_DESTROY_SOCKETS; import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING; import static com.android.server.connectivity.ConnectivityFlags.NAMESPACE_TETHERING_BOOT; @@ -389,6 +390,7 @@ import com.android.server.connectivity.PermissionMonitor; import com.android.server.connectivity.ProfileNetworkPreferenceInfo; import com.android.server.connectivity.ProxyTracker; import com.android.server.connectivity.QosCallbackTracker; +import com.android.server.connectivity.QuicConnectionCloser; import com.android.server.connectivity.SatelliteAccessController; import com.android.server.connectivity.UidRangeUtils; import com.android.server.connectivity.VpnNetworkPreferenceInfo; @@ -1104,6 +1106,13 @@ public class ConnectivityService extends IConnectivityManager.Stub { // Flag to drop packets to VPN addresses ingressing via non-VPN interfaces. private final boolean mIngressToVpnAddressFiltering; + // Flag to close QUIC connection for registered sockets when apps lose network access. + private final boolean mCloseQuicConnection; + + // This is null if mCloseQuicConnection is false + @Nullable + private final QuicConnectionCloser mQuicConnectionCloser; + /** * Implements support for the legacy "one network per network type" model. * @@ -1825,6 +1834,14 @@ public class ConnectivityService extends IConnectivityManager.Stub { handler.sendMessageDelayed( handler.obtainMessage(EVENT_INITIAL_EVALUATION_TIMEOUT, network), delayMs); } + + /** + * Create {@link QuicConnectionCloser}. + */ + public QuicConnectionCloser makeQuicConnectionCloser( + final SparseArray networkForNetId, final Handler handler) { + return new QuicConnectionCloser(networkForNetId, handler); + } } public ConnectivityService(Context context) { @@ -2133,6 +2150,13 @@ public class ConnectivityService extends IConnectivityManager.Stub { && mDeps.isFeatureNotChickenedOut(mContext, INGRESS_TO_VPN_ADDRESS_FILTERING); mL2capNetworkProvider = mDeps.makeL2capNetworkProvider(mContext); + + mCloseQuicConnection = mDeps.isFeatureEnabled(context, CLOSE_QUIC_CONNECTION); + if (mCloseQuicConnection) { + mQuicConnectionCloser = mDeps.makeQuicConnectionCloser(mNetworkForNetId, mHandler); + } else { + mQuicConnectionCloser = null; + } } /** @@ -3687,6 +3711,11 @@ public class ConnectivityService extends IConnectivityManager.Stub { } catch (SocketException | InterruptedIOException | ErrnoException e) { loge("Failed to destroy sockets: " + e); } + + if (mCloseQuicConnection) { + mQuicConnectionCloser.closeQuicConnectionByUids(uids); + } + mDestroySocketPendingUids.clear(); } @@ -4516,6 +4545,12 @@ public class ConnectivityService extends IConnectivityManager.Stub { mNetworkActivityTracker.dump(pw); pw.decreaseIndent(); + pw.println(); + pw.println("Close QUIC connection: " + mCloseQuicConnection); + if (mCloseQuicConnection) { + mQuicConnectionCloser.dump(pw); + } + pw.println(); pw.println("Multicast routing supported: " + (mMulticastRoutingCoordinatorService != null)); @@ -15125,8 +15160,33 @@ public class ConnectivityService extends IConnectivityManager.Stub { return mIngressToVpnAddressFiltering; case QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER: return mQueueNetworkAgentEventsInSystemServer; + case CLOSE_QUIC_CONNECTION: + return mCloseQuicConnection; default: throw new IllegalArgumentException("Unknown flag: " + featureFlag); } } + + @Override + public void registerQuicConnectionClosePayload(final ParcelFileDescriptor pfd, + final byte[] payload) { + if (!mCloseQuicConnection) { + IoUtils.closeQuietly(pfd); + return; + } + // pfd is closed by registerQuicConnectionClosePayload + mQuicConnectionCloser.registerQuicConnectionClosePayload(mDeps.getCallingUid(), + pfd, payload); + } + + @Override + public void unregisterQuicConnectionClosePayload(final ParcelFileDescriptor pfd) { + if (!mCloseQuicConnection) { + IoUtils.closeQuietly(pfd); + return; + } + // pfd is closed by unregisterQuicConnectionClosePayload + mQuicConnectionCloser.unregisterQuicConnectionClosePayload(pfd); + } + } diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java index 74bd2351ac..fd24951049 100644 --- a/service/src/com/android/server/connectivity/ConnectivityFlags.java +++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java @@ -65,6 +65,8 @@ public final class ConnectivityFlags { public static final String QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER = "queue_network_agent_events_in_system_server"; + public static final String CLOSE_QUIC_CONNECTION = "close_quic_connection"; + private boolean mNoRematchAllRequestsOnRegister; /** diff --git a/service/src/com/android/server/connectivity/QuicConnectionCloser.java b/service/src/com/android/server/connectivity/QuicConnectionCloser.java new file mode 100644 index 0000000000..ce5d61c92b --- /dev/null +++ b/service/src/com/android/server/connectivity/QuicConnectionCloser.java @@ -0,0 +1,406 @@ +/* + * 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.connectivity; + +import static android.system.OsConstants.ENOENT; +import static android.system.OsConstants.SOL_SOCKET; +import static android.system.OsConstants.SO_SNDTIMEO; + +import android.annotation.NonNull; +import android.net.Network; +import android.net.NetworkUtils; +import android.os.Handler; +import android.os.ParcelFileDescriptor; +import android.system.ErrnoException; +import android.system.Os; +import android.system.StructTimeval; +import android.util.ArrayMap; +import android.util.IndentingPrintWriter; +import android.util.Log; +import android.util.SparseArray; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.net.module.util.HandlerUtils; +import com.android.net.module.util.SharedLog; +import com.android.net.module.util.SkDestroyListener; +import com.android.net.module.util.netlink.InetDiagMessage; + +import libcore.io.IoUtils; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +public class QuicConnectionCloser { + private static final String TAG = QuicConnectionCloser.class.getSimpleName(); + + private static final int SOCKET_WRITE_TIMEOUT_MS = 100; + + // Map from socket cookie to QUIC connection close information + private final Map + mRegisteredQuicConnectionCloseInfos = new ArrayMap<>(); + + // The maximum number of QUIC connection close information entries that can be registered + // concurrently. + private static final int MAX_REGISTERED_QUIC_CONNECTION_CLOSE_INFO = 1000; + + private final Handler mHandler; + + // Reference to ConnectivityService#mNetworkForNetId, must be synchronized on itself. + // mHandler in this class is associated with the ConnectivityService handler thread. + // The only code that can update mNetworkForNetId runs on that thread, so no entries can be + // updated while the code in this class is running on mHandler thread. + @GuardedBy("mNetworkForNetId") + private final SparseArray mNetworkForNetId; + + @NonNull + private final Dependencies mDeps; + + /** + * Class to store the necessary information for closing a QUIC connection. + */ + private static class QuicConnectionCloseInfo { + public final int uid; + public final int netId; + public final long cookie; + public final InetSocketAddress src; + public final InetSocketAddress dst; + public final byte[] payload; + + QuicConnectionCloseInfo(int uid, int netId, long cookie, InetSocketAddress src, + InetSocketAddress dst, byte[] payload) { + this.uid = uid; + this.netId = netId; + this.cookie = cookie; + this.src = src; + this.dst = dst; + this.payload = payload; + } + + @Override + public String toString() { + return "QuicConnectionCloseInfo{" + + "uid: " + uid + + ", netId: " + netId + + ", cookie: " + cookie + + ", src: " + src + + ", dst: " + dst + + ", payload length: " + payload.length + + "}"; + } + } + + public static class Dependencies { + /** + * Send a UDP packet with the specified source and destination address and port over the + * specified network. + * + * @param network The {@link Network} over which the UDP packet will be sent. + * @param src The source {@link InetSocketAddress} of the UDP packet to be sent. + * @param dst The destination {@link InetSocketAddress} of the UDP packet to be sent. + * @param payload The UDP payload to be sent. + */ + public void sendQuicConnectionClosePayload(final Network network, + final InetSocketAddress src, final InetSocketAddress dst, final byte[] payload) + throws IOException, ErrnoException { + final DatagramSocket socket = new DatagramSocket(src); + network.bindSocket(socket); + socket.connect(dst); + Os.setsockoptTimeval(socket.getFileDescriptor$(), SOL_SOCKET, SO_SNDTIMEO, + StructTimeval.fromMillis(SOCKET_WRITE_TIMEOUT_MS)); + Os.write(socket.getFileDescriptor$(), payload, 0 /* byteOffset */, payload.length); + } + + /** + * Call {@link InetDiagMessage#destroyUdpSocket} + */ + public void destroyUdpSocket(final InetSocketAddress src, final InetSocketAddress dst, + final long cookie) + throws SocketException, InterruptedIOException, ErrnoException { + InetDiagMessage.destroyUdpSocket(src, dst, 0 /* ifindex */, cookie); + } + + /** + * Call {@link SkDestroyListener#makeSkDestroyListener} + */ + public SkDestroyListener makeSkDestroyListener(final Consumer consumer, + final Handler handler) { + return SkDestroyListener.makeSkDestroyListener(consumer, false /* monitorTcpSocket */, + true /* monitorUdpSocket */, handler, new SharedLog(TAG)); + } + + /** + * Call {@link NetworkUtils#getSocketCookie} + */ + public long getSocketCookie(final FileDescriptor fd) throws ErrnoException { + return NetworkUtils.getSocketCookie(fd); + } + + /** + * Call {@link Os#getsockoptInt} + */ + public int getsockoptInt(final FileDescriptor fd, final int level, final int option) + throws ErrnoException { + return Os.getsockoptInt(fd, level, option); + } + + /** + * Call {@link Os#getsockname}} + */ + public InetSocketAddress getsockname(final FileDescriptor fd) throws ErrnoException { + return (InetSocketAddress) Os.getsockname(fd); + } + + /** + * Call {@link Os#getpeername} + */ + public InetSocketAddress getpeername(final FileDescriptor fd) throws ErrnoException { + return (InetSocketAddress) Os.getpeername(fd); + } + } + + public QuicConnectionCloser(final SparseArray networkForNetId, + final Handler handler) { + this(networkForNetId, handler, new Dependencies()); + } + + @VisibleForTesting + public QuicConnectionCloser(final SparseArray networkForNetId, + final Handler handler, final Dependencies deps) { + mNetworkForNetId = networkForNetId; + mHandler = handler; + mDeps = deps; + + // handleUdpSocketDestroy must be posted to the thread to avoid racing with + // handleUnregisterQuicConnectionCloseInfo, even though they both run on the same thread. + // Specifically, the following can happen: + // unregisterQuicConnectionClosePayload posts handleUnregisterQuicConnectionCloseInfo to + // the handler and then closes the fd. + // The close() will cause the kernel to enqueue a netlink message to the SkDestroyHandler's + // fd. + // It's possible that the MessageQueue of the handler sees both the SkDestroyHandler's fd + // and the MessageQueue fd go active at the same time, and chooses to run the netlink + // event first. + // As a result, handleUdpSocketDestroy runs before handleUnregisterQuicConnectionCloseInfo, + // and the code incorrectly sends a close packet for a socket that the app has already + // unregistered. + // Posting handleUdpSocketDestroy to the handler ensures that it always runs after + // handleUnregisterQuicConnectionCloseInfo. It doesn't matter if the + // handleUdpSocketDestroy is delayed, because it will only send a packet if + // mRegisteredQuicConnectionCloseInfos contains the socket cookie, and socket cookies are + // never reused. + final SkDestroyListener udpSkDestroyListener = mDeps.makeSkDestroyListener( + (inetDiagMessage) -> handler.post(() -> handleUdpSocketDestroy(inetDiagMessage)), + handler); + handler.post(udpSkDestroyListener::start); + } + + private void ensureRunningOnHandlerThread() { + HandlerUtils.ensureRunningOnHandlerThread(mHandler); + } + + /** + * Close registered QUIC connection by uids + * + * @param uids target uids to close QUIC connections + */ + public void closeQuicConnectionByUids(final Set uids) { + ensureRunningOnHandlerThread(); + + for (Iterator> it = + mRegisteredQuicConnectionCloseInfos.entrySet().iterator(); it.hasNext();) { + final QuicConnectionCloseInfo info = it.next().getValue(); + if (uids.contains(info.uid)) { + closeQuicConnection(info, true /* destroySocket */); + it.remove(); + } + } + } + + private NetworkAgentInfo getNetworkAgentInfoForNetId(int netId) { + synchronized (mNetworkForNetId) { + return mNetworkForNetId.get(netId); + } + } + + private void closeQuicConnection(final QuicConnectionCloseInfo info, + final boolean destroySocket) { + ensureRunningOnHandlerThread(); + + Log.d(TAG, "Close QUIC socket for " + info + ", destroySocket=" + destroySocket); + final NetworkAgentInfo nai = getNetworkAgentInfoForNetId(info.netId); + if (nai == null || !nai.linkProperties.getAddresses().contains(info.src.getAddress())) { + // The device should not send a packet with an unused source address. + return; + } + + if (destroySocket) { + try { + mDeps.destroyUdpSocket(info.src, info.dst, info.cookie); + } catch (ErrnoException | SocketException | InterruptedIOException e) { + if (e instanceof ErrnoException && ((ErrnoException) e).errno == ENOENT) { + // This can happen if the socket is already closed, but unregister message is + // not processed yet. + return; + } + Log.e(TAG, "Failed to destroy QUIC socket for " + info + ": " + e); + return; + } + } + + try { + mDeps.sendQuicConnectionClosePayload(nai.network(), info.src, info.dst, info.payload); + } catch (ErrnoException | IOException e) { + Log.e(TAG, "Failed to send registered QUIC connection close payload for " + + info + ": " + e); + } + } + + /** + * Close QUIC connection if the registered socket was destroyed + * + * @param inetDiagMessage {@link InetDiagMessage} received from kernel + */ + private void handleUdpSocketDestroy(final InetDiagMessage inetDiagMessage) { + ensureRunningOnHandlerThread(); + + final long cookie = inetDiagMessage.inetDiagMsg.id.cookie; + final QuicConnectionCloseInfo info = mRegisteredQuicConnectionCloseInfos.remove(cookie); + if (info == null) { + // Destroyed socket is not registered or already unregistered. + return; + } + + // App registered a QUIC connection close payload and this socket, but the socket was + // closed before the socket was unregistered. + // This can happen if the app crashes or is killed. + closeQuicConnection(info, false /* destroySocket */); + } + + /** + * Register the QUIC connection close information + * + * @param info {@link QuicConnectionCloseInfo} to register + */ + private void handleRegisterQuicConnectionCloseInfo(final QuicConnectionCloseInfo info) { + ensureRunningOnHandlerThread(); + + if (mRegisteredQuicConnectionCloseInfos.size() + >= MAX_REGISTERED_QUIC_CONNECTION_CLOSE_INFO) { + Log.e(TAG, "Failed to register QUIC connection close information." + + " number of registered information exceeded " + + MAX_REGISTERED_QUIC_CONNECTION_CLOSE_INFO); + return; + } + mRegisteredQuicConnectionCloseInfos.put(info.cookie, info); + } + + // TODO: Use OsConstants.SO_MARK once this API is available + private static final int SO_MARK = 36; + + /** + * Register QUIC socket and connection close payload + * + * @param uid The uid of the socket owner + * @param pfd The {@link ParcelFileDescriptor} for the connected UDP socket. + * @param payload The UDP payload that can close QUIC connection. + */ + public void registerQuicConnectionClosePayload(final int uid, final ParcelFileDescriptor pfd, + final byte[] payload) { + try { + final FileDescriptor fd = pfd.getFileDescriptor(); + // See FWMARK_NET_ID_MASK in Fwmark.h + final int netId = mDeps.getsockoptInt(fd, SOL_SOCKET, SO_MARK) & 0xffff; + // The peer name could change if the socket is reconnected to a different server. + // But, it is the caller's responsibility to avoid this, as explicitly stated in the + // Javadoc of ConnectivityManager#registerQuicConnectionClosePayload. + final QuicConnectionCloseInfo info = new QuicConnectionCloseInfo( + uid, + netId, + mDeps.getSocketCookie(fd), + mDeps.getsockname(fd), + mDeps.getpeername(fd), + payload); + mHandler.post(() -> handleRegisterQuicConnectionCloseInfo(info)); + } catch (ErrnoException e) { + Log.e(TAG, "Failed to register QUIC connection close information", e); + } finally { + // |pfd| must remain open until the message is posted to the handler thread, because + // doing so ensures that the socket is not closed even if the app calls close(). + // Keeping it open avoids the following threading issue with SkDestroyListener : + // If the PFD was closed before posting the message, then SkDestroyListener might + // get a "socket destroyed" message from Netlink immediately and post to the handler + // thread before this method posts its own message, resulting in a leak of the + // mRegisteredQuicConnectionCloseInfo entry. + // While the PFD is held, Netlink will not send the socket destroy message to + // SkDestroyListener, and thus the message posted by SkDestroyListener as + // a reaction to it will be processed after the message posted by this method. + IoUtils.closeQuietly(pfd); + } + } + + /** + * Unregister the QUIC connection close information + * + * @param cookie cookie of the socket whose connection close information should be unregistered + */ + private void handleUnregisterQuicConnectionCloseInfo(final long cookie) { + ensureRunningOnHandlerThread(); + + mRegisteredQuicConnectionCloseInfos.remove(cookie); + } + + /** + * Unregister the QUIC socket + * + * @param pfd The {@link ParcelFileDescriptor} for the UDP socket. + */ + public void unregisterQuicConnectionClosePayload(final ParcelFileDescriptor pfd) { + try { + final long cookie = mDeps.getSocketCookie(pfd.getFileDescriptor()); + mHandler.post(() -> handleUnregisterQuicConnectionCloseInfo(cookie)); + } catch (ErrnoException e) { + Log.e(TAG, "Failed to unregister QUIC connection close information: " + e); + } finally { + // |pfd| must be closed after posting the message to the handler thread to avoid the + // threading issue with SkDestroyListener. + // See the comment in registerQuicConnectionClosePayload. + IoUtils.closeQuietly(pfd); + } + } + + /** + * Dump QUIC connection closer information + */ + public void dump(final IndentingPrintWriter pw) { + pw.println("Registered QUIC connection close information: " + + mRegisteredQuicConnectionCloseInfos.size()); + pw.increaseIndent(); + for (QuicConnectionCloseInfo info: mRegisteredQuicConnectionCloseInfos.values()) { + pw.println(info); + } + pw.decreaseIndent(); + } +} diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java index 87c2b9e5d1..e20e457b01 100644 --- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java +++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java @@ -21,6 +21,7 @@ import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static android.Manifest.permission.ACCESS_NETWORK_STATE; import static android.Manifest.permission.CONNECTIVITY_INTERNAL; import static android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS; +import static android.Manifest.permission.MANAGE_TEST_NETWORKS; import static android.Manifest.permission.NETWORK_FACTORY; import static android.Manifest.permission.NETWORK_SETTINGS; import static android.Manifest.permission.NETWORK_SETUP_WIZARD; @@ -100,6 +101,11 @@ import static android.provider.Settings.Global.NETWORK_METERED_MULTIPATH_PREFERE import static android.system.OsConstants.AF_INET; import static android.system.OsConstants.AF_INET6; import static android.system.OsConstants.AF_UNSPEC; +import static android.system.OsConstants.ECONNABORTED; +import static android.system.OsConstants.EDESTADDRREQ; +import static android.system.OsConstants.IPPROTO_UDP; +import static android.system.OsConstants.POLLIN; +import static android.system.OsConstants.SOCK_DGRAM; import static com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity; import static com.android.compatibility.common.util.SystemUtil.runShellCommand; @@ -182,7 +188,9 @@ import android.os.ConditionVariable; import android.os.Handler; import android.os.Looper; import android.os.MessageQueue; +import android.os.ParcelFileDescriptor; import android.os.Process; +import android.os.RemoteException; import android.os.ServiceManager; import android.os.SystemClock; import android.os.UserHandle; @@ -190,6 +198,9 @@ import android.os.VintfRuntimeInfo; import android.platform.test.annotations.AppModeFull; import android.provider.DeviceConfig; import android.provider.Settings; +import android.system.ErrnoException; +import android.system.Os; +import android.system.StructPollfd; import android.telephony.SubscriptionManager; import android.telephony.TelephonyManager; import android.text.TextUtils; @@ -205,6 +216,10 @@ import com.android.internal.util.ArrayUtils; import com.android.modules.utils.build.SdkLevel; import com.android.net.module.util.CollectionUtils; import com.android.net.module.util.DnsPacket; +import com.android.net.module.util.Struct; +import com.android.net.module.util.structs.Ipv4Header; +import com.android.net.module.util.structs.Ipv6Header; +import com.android.net.module.util.structs.UdpHeader; import com.android.networkstack.apishim.ConnectivityManagerShimImpl; import com.android.networkstack.apishim.ConstantsShim; import com.android.networkstack.apishim.NetworkInformationShimImpl; @@ -228,6 +243,7 @@ import com.android.testutils.TestableNetworkCallback; import junit.framework.AssertionFailedError; +import libcore.io.IoUtils; import libcore.io.Streams; import org.junit.After; @@ -253,6 +269,7 @@ import java.net.MalformedURLException; import java.net.Socket; import java.net.SocketException; import java.net.URL; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -352,6 +369,15 @@ public class ConnectivityManagerTest { // Re-connecting to the AP, obtaining an IP address, revalidating can take a long time private static final long WIFI_CONNECT_TIMEOUT_MS = 60_000L; + // Timeout for waiting the QUIC connection close information registration/unregistration + private static final long QUIC_CONNECTION_CLOSE_INFO_REGISTRATION_TIMEOUT_MS = 500L; + + // Timeout for waiting the QUIC connection close packet to be sent + private static final long QUIC_CONNECTION_CLOSE_PACKET_TIMEOUT_MS = 200L; + + // Name of the feature flag for closing quic connection + private static final String CLOSE_QUIC_CONNECTION = "close_quic_connection"; + private Context mContext; private Instrumentation mInstrumentation; private ConnectivityManager mCm; @@ -2389,8 +2415,8 @@ public class ConnectivityManagerTest { waitForAvailable(cb); } - private void waitForAvailable(@NonNull final TestableNetworkCallback cb) { - cb.eventuallyExpect(CallbackEntry.AVAILABLE, NETWORK_CALLBACK_TIMEOUT_MS, + private CallbackEntry.Available waitForAvailable(@NonNull final TestableNetworkCallback cb) { + return cb.eventuallyExpect(CallbackEntry.AVAILABLE, NETWORK_CALLBACK_TIMEOUT_MS, c -> c instanceof CallbackEntry.Available); } @@ -4131,4 +4157,302 @@ public class ConnectivityManagerTest { assertThrows(UnsupportedOperationException.class, () -> mCm.tether("iface")); assertThrows(UnsupportedOperationException.class, () -> mCm.untether("iface")); } + + private ParcelFileDescriptor setupTestNetworkAndGetFd() { + return runWithShellPermissionIdentity(() -> { + final TestNetworkManager tnm = mContext.getSystemService(TestNetworkManager.class); + final List linkAddresses = List.of(new LinkAddress("192.0.2.2/24"), + new LinkAddress("2001:db8:1:2::2/64")); + final TestNetworkInterface iface = tnm.createTunInterface(linkAddresses); + tnm.setupTestNetwork(iface.getInterfaceName(), new Binder()); + return iface.getFileDescriptor(); + }, MANAGE_TEST_NETWORKS); + } + + private Network getTestNetwork() { + final TestableNetworkCallback callback = networkCallbackRule.requestNetwork( + new NetworkRequest.Builder() + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) + .addTransportType(TRANSPORT_TEST) + .build() + ); + return waitForAvailable(callback).getNetwork(); + } + + private void waitForQuicConnectionCloseInfoRegistration(final int myUid, + final boolean expectRegistered) + throws ErrnoException, RemoteException, InterruptedException { + final long timeout = SystemClock.elapsedRealtime() + + QUIC_CONNECTION_CLOSE_INFO_REGISTRATION_TIMEOUT_MS; + while (timeout > SystemClock.elapsedRealtime()) { + if (DumpTestUtils.dumpServiceWithShellPermission( + Context.CONNECTIVITY_SERVICE, "--short") + .contains("QuicConnectionCloseInfo{uid: " + myUid) == expectRegistered) { + return; + } + Thread.sleep(50); + } + fail("Failed to register/unregister QUIC connection close information in " + + QUIC_CONNECTION_CLOSE_INFO_REGISTRATION_TIMEOUT_MS + + "ms, expectRegistered=" + expectRegistered); + } + + private void doTestRegisterQuicConnectionClosePayload(final boolean isV6, + final boolean unregister, final boolean closeSocket, final boolean blockNetwork, + final boolean expectPacketSent) throws Exception { + final InetAddress dstAddress = isV6 ? InetAddresses.parseNumericAddress("2001:db8:1:2::3") + : InetAddresses.parseNumericAddress("192.0.2.3"); + final InetSocketAddress dstSockAddress = new InetSocketAddress(dstAddress, 443); + final int myUid = Process.myUid(); + + final ParcelFileDescriptor tunFd = setupTestNetworkAndGetFd(); + final Network testNetwork = getTestNetwork(); + + // Firewall chain status will be restored after the test. + final boolean wasChainEnabled = runWithShellPermissionIdentity(() -> + mCm.getFirewallChainEnabled(FIREWALL_CHAIN_BACKGROUND), NETWORK_SETTINGS); + final int previousUidFirewallRule = runWithShellPermissionIdentity(() -> + mCm.getUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, myUid), NETWORK_SETTINGS); + runWithShellPermissionIdentity(() -> { + mCm.setFirewallChainEnabled(FIREWALL_CHAIN_BACKGROUND, true /* enable */); + mCm.setUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, myUid, FIREWALL_RULE_ALLOW); + }, NETWORK_SETTINGS); + + final FileDescriptor sock = Os.socket(isV6 ? AF_INET6 : AF_INET, SOCK_DGRAM, IPPROTO_UDP); + final ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(sock.getInt$()); + testNetwork.bindSocket(sock); + Os.connect(sock, dstSockAddress); + final InetSocketAddress srcSockAddress = (InetSocketAddress) Os.getsockname(sock); + + testAndCleanup(() -> { + final Random random = new Random(); + final byte[] payload = new byte[100]; + random.nextBytes(payload); + + // register/unregisterQuicConnectionClosePayload are oneway binder calls, + // while setUidFirewallRule is a two-way binder call. So if + // setUidFirewallRule is called immediately after them, it's possible that + // setUidFirewallRule could be processed before them. + // To ensure that uid networking is blocked after the connection close information + // is registered/unregistered, wait for the registration/unregistration. + mCm.registerQuicConnectionClosePayload(pfd, payload); + waitForQuicConnectionCloseInfoRegistration(myUid, true /* expectRegistered */); + if (unregister) { + mCm.unregisterQuicConnectionClosePayload(pfd); + waitForQuicConnectionCloseInfoRegistration(myUid, false /* expectRegistered */); + } + + if (closeSocket) { + Os.close(sock); + pfd.close(); + } + + if (blockNetwork) { + runWithShellPermissionIdentity(() -> + mCm.setUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, myUid, + FIREWALL_RULE_DENY), NETWORK_SETTINGS); + } + + final long timeout = SystemClock.elapsedRealtime() + + QUIC_CONNECTION_CLOSE_PACKET_TIMEOUT_MS; + long remainingTimeMs; + while ((remainingTimeMs = timeout - SystemClock.elapsedRealtime()) > 0) { + final StructPollfd pollfd = new StructPollfd(); + pollfd.events = (short) POLLIN; + pollfd.fd = tunFd.getFileDescriptor(); + final int ret = Os.poll(new StructPollfd[] { pollfd }, (int) remainingTimeMs); + if (ret == 0) { + continue; + } + + final byte[] recvData = new byte[200]; + final int readSize = Os.read(tunFd.getFileDescriptor(), recvData, + 0 /* byteOffset */, recvData.length); + + if (readSize < payload.length + || !Arrays.equals(payload, 0, payload.length, + recvData, readSize - payload.length, readSize)) { + continue; + } + // If the control comes here then the payload was received, otherwise poll would + // have returned 0 or the test above would have matched it and gone to continue. + if (!expectPacketSent) { + fail("Unexpectedly received the QUIC connection close packet."); + } + + final ByteBuffer buf = ByteBuffer.wrap(recvData, 0 /* offset */, readSize); + if (isV6) { + final Ipv6Header header = Struct.parse(Ipv6Header.class, buf); + assertEquals(srcSockAddress.getAddress(), header.srcIp); + assertEquals(dstSockAddress.getAddress(), header.dstIp); + } else { + final Ipv4Header header = Struct.parse(Ipv4Header.class, buf); + assertEquals(srcSockAddress.getAddress(), header.srcIp); + assertEquals(dstSockAddress.getAddress(), header.dstIp); + } + final UdpHeader udpHeader = Struct.parse(UdpHeader.class, buf); + assertEquals(srcSockAddress.getPort(), udpHeader.srcPort); + assertEquals(dstSockAddress.getPort(), udpHeader.dstPort); + assertEquals(payload.length + Struct.getSize(UdpHeader.class), udpHeader.length); + + if (!closeSocket) { + // After the socket is destroyed and QUIC connection is closed by + // ConnectivityService, writing to the UDP socket should throw. + try { + Os.write(sock, payload, 0 /* byteOffset */, payload.length); + fail("Write to the destroyed socket must throw."); + } catch (ErrnoException e) { + assertEquals(EDESTADDRREQ, e.errno); + } + try { + Os.sendto(sock, payload, 0 /* byteOffset */, + payload.length, 0 /* flags */, dstSockAddress); + fail("Sendto with the destroyed socket must throw."); + } catch (ErrnoException e) { + assertEquals(ECONNABORTED, e.errno); + } + } + return; + } + if (expectPacketSent) { + fail("Did not receive the QUIC connection close packet."); + } + }, /* cleanup */ () -> { + runWithShellPermissionIdentity(() -> { + mContext.getSystemService(TestNetworkManager.class).teardownTestNetwork( + testNetwork); + }, MANAGE_TEST_NETWORKS); + IoUtils.closeQuietly(tunFd); + IoUtils.closeQuietly(sock); + IoUtils.closeQuietly(pfd); + }, /* cleanup */ () -> { + // Restore firewall chain global status + runWithShellPermissionIdentity(() -> { + mCm.setFirewallChainEnabled(FIREWALL_CHAIN_BACKGROUND, wasChainEnabled); + }, NETWORK_SETTINGS); + }, /* cleanup */ () -> { + // Restore firewall chain status for myUid + runWithShellPermissionIdentity(() -> { + try { + mCm.setUidFirewallRule(FIREWALL_CHAIN_BACKGROUND, myUid, + previousUidFirewallRule); + } catch (IllegalStateException ignored) { + // Removing match causes an exception when the rule entry for the uid does + // not exist. But this is fine and can be ignored. + } + }, NETWORK_SETTINGS); + }); + } + + @Test + @IgnoreUpTo(Build.VERSION_CODES.R) + @ConnectivityModuleTest + @AppModeFull(reason = "Cannot create test network in instant app mode") + public void testRegisterQuicConnectionClosePayload_blockNetwork() throws Exception { + assumeTrue(mCm.isConnectivityServiceFeatureEnabledForTesting(CLOSE_QUIC_CONNECTION)); + + // Network is blocked while the connection close payload is registered. + // Packet should be sent. + doTestRegisterQuicConnectionClosePayload( + false /* isV6 */, false /* unregister */, false /* closeSocket */, + true /* blockNetwork */, true /* expectPacketSent */); + doTestRegisterQuicConnectionClosePayload( + true /* isV6 */, false /* unregister */, false /* closeSocket */, + true /* blockNetwork */, true /* expectPacketSent */); + + // Network is blocked after the connection close payload is unregistered. + // Packet should not be sent. + doTestRegisterQuicConnectionClosePayload( + false /* isV6 */, true /* unregister */, false /* closeSocket */, + true /* blockNetwork */, false /* expectPacketSent */); + doTestRegisterQuicConnectionClosePayload( + true /* isV6 */, true /* unregister */, false /* closeSocket */, + true /* blockNetwork */, false /* expectPacketSent */); + } + + @Test + @IgnoreUpTo(Build.VERSION_CODES.R) + @ConnectivityModuleTest + @AppModeFull(reason = "Cannot create test network in instant app mode") + public void testRegisterQuicConnectionClosePayload_closeSocket() throws Exception { + assumeTrue(mCm.isConnectivityServiceFeatureEnabledForTesting(CLOSE_QUIC_CONNECTION)); + + // Registered socket is closed while the connection close payload is registered. + // Packet should be sent. This simulates that apps crash or are killed. + doTestRegisterQuicConnectionClosePayload( + false /* isV6 */, false /* unregister */, true /* closeSocket */, + false /* blockNetwork */, true /* expectPacketSent */); + doTestRegisterQuicConnectionClosePayload( + true /* isV6 */, false /* unregister */, true /* closeSocket */, + false /* blockNetwork */, true /* expectPacketSent */); + } + + /** + * Repeat register a connection close payload, unregister it, and then close the socket. + * The registered payload must not be sent because it is unregistered before the socket + * is closed. + * This test tries to detect threading issues between + * register/unregisterQuicConnectionClosePayload and socket destroy message handling. + */ + @Test + @IgnoreUpTo(Build.VERSION_CODES.R) + @ConnectivityModuleTest + @AppModeFull(reason = "Cannot create test network in instant app mode") + public void testRegisterQuicConnectionClosePayload_closeSocketAfterUnregister() + throws Exception { + assumeTrue(mCm.isConnectivityServiceFeatureEnabledForTesting(CLOSE_QUIC_CONNECTION)); + + final ParcelFileDescriptor tunFd = setupTestNetworkAndGetFd(); + final Network testNetwork = getTestNetwork(); + + final Random random = new Random(); + final byte[] payload = new byte[100]; + random.nextBytes(payload); + + final InetSocketAddress dstSockAddress = + new InetSocketAddress(InetAddresses.parseNumericAddress("2001:db8:1:2::3"), 443); + for (int i = 0; i < 100; i++) { + final FileDescriptor sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + testNetwork.bindSocket(sock); + Os.connect(sock, dstSockAddress); + + final ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(sock.getInt$()); + mCm.registerQuicConnectionClosePayload(pfd, payload); + mCm.unregisterQuicConnectionClosePayload(pfd); + Os.close(sock); + pfd.close(); + } + + testAndCleanup(() -> { + final long timeout = SystemClock.elapsedRealtime() + + QUIC_CONNECTION_CLOSE_PACKET_TIMEOUT_MS; + long remainingTimeMs; + while ((remainingTimeMs = timeout - SystemClock.elapsedRealtime()) > 0) { + StructPollfd pollfd = new StructPollfd(); + pollfd.events = (short) POLLIN; + pollfd.fd = tunFd.getFileDescriptor(); + final int ret = Os.poll(new StructPollfd[]{pollfd}, (int) remainingTimeMs); + if (ret == 0) { + continue; + } + + final byte[] recvData = new byte[200]; + final int readSize = Os.read(tunFd.getFileDescriptor(), recvData, + 0 /* byteOffset */, recvData.length); + if (readSize < payload.length + || !Arrays.equals(payload, 0, payload.length, + recvData, readSize - payload.length, readSize)) { + continue; + } + fail("Unexpectedly received the QUIC connection close packet."); + } + }, /* cleanup */ () -> { + runWithShellPermissionIdentity(() -> { + mContext.getSystemService(TestNetworkManager.class).teardownTestNetwork( + testNetwork); + }, MANAGE_TEST_NETWORKS); + IoUtils.closeQuietly(tunFd); + }); + } } diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java index 8c90e01086..bd26b95764 100755 --- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java +++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java @@ -2188,6 +2188,7 @@ public class ConnectivityServiceTest { case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS: case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS: case ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN: + case ConnectivityFlags.CLOSE_QUIC_CONNECTION: case ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION: return true; default: diff --git a/tests/unit/java/com/android/server/connectivity/QuicConnectionCloserTest.kt b/tests/unit/java/com/android/server/connectivity/QuicConnectionCloserTest.kt new file mode 100644 index 0000000000..9e1c090f2c --- /dev/null +++ b/tests/unit/java/com/android/server/connectivity/QuicConnectionCloserTest.kt @@ -0,0 +1,232 @@ +/* + * 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.connectivity + +import android.net.InetAddresses +import android.net.LinkAddress +import android.net.LinkProperties +import android.net.Network +import android.net.TEST_IFACE +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.system.OsConstants.SOL_SOCKET +import android.util.SparseArray +import com.android.net.module.util.SkDestroyListener +import com.android.net.module.util.netlink.InetDiagMessage +import com.android.net.module.util.netlink.StructInetDiagSockId +import com.android.net.module.util.netlink.StructNlMsgHdr +import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo +import com.android.testutils.DevSdkIgnoreRunner +import com.android.testutils.visibleOnHandlerThread +import java.net.InetSocketAddress +import java.util.function.Consumer +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.InOrder +import org.mockito.Mockito.any +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.eq +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +private const val TEST_UID = 1234 +private const val TEST_NETID = 789 +private const val TEST_SOCKET_COOKIE = 12321L + +// TODO: Use OsConstants.SO_MARK once this API is available +private const val SO_MARK = 36 + +private val TEST_SRC_ADDRESS = InetAddresses.parseNumericAddress("2001:db8:1:2::2") +private val TEST_SRC_SOCKET_ADDRESS = InetSocketAddress( + TEST_SRC_ADDRESS, + 1234 +) +private val TEST_DST_SOCKET_ADDRESS = InetSocketAddress( + InetAddresses.parseNumericAddress("2001:db8:1:2::3"), + 443 +) +private val TEST_LP = LinkProperties().apply { + interfaceName = TEST_IFACE + addLinkAddress(LinkAddress(TEST_SRC_ADDRESS, 64)) +} +private val TEST_NETWORK = Network(TEST_NETID) +private val TEST_PAYLOAD = byteArrayOf(0, 1, 2, 3, 4, 5) + +@RunWith(DevSdkIgnoreRunner::class) +@IgnoreUpTo(Build.VERSION_CODES.R) +class QuicConnectionCloserTest { + private val pfd = mock(ParcelFileDescriptor::class.java) + private val skDestroyListener = mock(SkDestroyListener::class.java) + private val handler by lazy { Handler(Looper.getMainLooper()) } + + private val mDeps = mock(QuicConnectionCloser.Dependencies::class.java).also { + doReturn(TEST_NETID).`when`(it).getsockoptInt(any(), eq(SOL_SOCKET), eq(SO_MARK)) + doReturn(TEST_SOCKET_COOKIE).`when`(it).getSocketCookie(any()) + doReturn(TEST_SRC_SOCKET_ADDRESS).`when`(it).getsockname(any()) + doReturn(TEST_DST_SOCKET_ADDRESS).`when`(it).getpeername(any()) + doReturn(skDestroyListener).`when`(it).makeSkDestroyListener(any(), any()) + } + + private val nai = mock(NetworkAgentInfo::class.java).also { + it.linkProperties = TEST_LP + doReturn(TEST_NETWORK).`when`(it).network() + } + + private val networkForNetId = SparseArray().apply { + put(TEST_NETID, nai) + } + + private val mQuicConnectionCloser = QuicConnectionCloser(networkForNetId, handler, mDeps) + + private fun InOrder.expectDestroyUdpSocket() = verify(mDeps).destroyUdpSocket( + TEST_SRC_SOCKET_ADDRESS, + TEST_DST_SOCKET_ADDRESS, + TEST_SOCKET_COOKIE + ) + + private fun InOrder.assertNoDestroyUdpSocket() = verify(mDeps, never()).destroyUdpSocket( + any(), + any(), + anyLong() + ) + + private fun InOrder.expectSendQuicConnectionClosePayload() = + verify(mDeps).sendQuicConnectionClosePayload( + TEST_NETWORK, + TEST_SRC_SOCKET_ADDRESS, + TEST_DST_SOCKET_ADDRESS, + TEST_PAYLOAD + ) + + private fun InOrder.assertNoSendQuicConnectionClosePayload() = + verify(mDeps, never()).sendQuicConnectionClosePayload( + any(), + any(), + any(), + any() + ) + + @Test + fun testCloseQuicConnectionByUids() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + visibleOnHandlerThread(handler) { + mQuicConnectionCloser.closeQuicConnectionByUids(setOf(TEST_UID)) + } + + val inOrder = inOrder(mDeps) + inOrder.expectDestroyUdpSocket() + inOrder.expectSendQuicConnectionClosePayload() + } + + @Test + fun testCloseQuicConnectionByUids_unregisterQuicConnectionCloseInfo() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + mQuicConnectionCloser.unregisterQuicConnectionClosePayload(pfd) + visibleOnHandlerThread(handler) { + mQuicConnectionCloser.closeQuicConnectionByUids(setOf(TEST_UID)) + } + + val inOrder = inOrder(mDeps) + inOrder.assertNoDestroyUdpSocket() + inOrder.assertNoSendQuicConnectionClosePayload() + } + + @Test + fun testCloseQuicConnectionByUids_networkDisconnected() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + // closeQuicConnectionByUids determines that the network is disconnected by + // checking if it's absent from the networkForNetId set. + synchronized (networkForNetId) { + networkForNetId.clear() + } + visibleOnHandlerThread(handler) { + mQuicConnectionCloser.closeQuicConnectionByUids(setOf(TEST_UID)) + } + + val inOrder = inOrder(mDeps) + inOrder.assertNoDestroyUdpSocket() + inOrder.assertNoSendQuicConnectionClosePayload() + } + + @Test + fun testCloseQuicConnectionByUids_networkAddressChange() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + // Update address to different address from TEST_SRC_SOCKET_ADDRESS + nai.linkProperties = LinkProperties().apply { + interfaceName = TEST_IFACE + addLinkAddress(LinkAddress(InetAddresses.parseNumericAddress("2001:db8:1:3::2"), 64)) + } + visibleOnHandlerThread(handler) { + mQuicConnectionCloser.closeQuicConnectionByUids(setOf(TEST_UID)) + } + + val inOrder = inOrder(mDeps) + inOrder.assertNoDestroyUdpSocket() + inOrder.assertNoSendQuicConnectionClosePayload() + } + + private fun getSkDestroyListenerCallback(): Consumer { + val captor = ArgumentCaptor.forClass(Consumer::class.java) + as ArgumentCaptor> + verify(mDeps).makeSkDestroyListener(captor.capture(), any()) + return captor.value + } + + @Test + fun testHandleUdpSocketDestroy() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + + val inetDiagMessage = InetDiagMessage(StructNlMsgHdr()) + inetDiagMessage.inetDiagMsg.id = StructInetDiagSockId( + TEST_SRC_SOCKET_ADDRESS, + TEST_DST_SOCKET_ADDRESS, + 0 /* ifindex */, + TEST_SOCKET_COOKIE + ) + getSkDestroyListenerCallback().accept(inetDiagMessage) + + val inOrder = inOrder(mDeps) + // DestroyUdpSocket is not called since the socket is already closed + inOrder.assertNoDestroyUdpSocket() + inOrder.expectSendQuicConnectionClosePayload() + } + + @Test + fun testHandleUdpSocketDestroy_unregisterBeforeSocketClose() { + mQuicConnectionCloser.registerQuicConnectionClosePayload(TEST_UID, pfd, TEST_PAYLOAD) + mQuicConnectionCloser.unregisterQuicConnectionClosePayload(pfd) + + val inetDiagMessage = InetDiagMessage(StructNlMsgHdr()) + inetDiagMessage.inetDiagMsg.id = StructInetDiagSockId( + TEST_SRC_SOCKET_ADDRESS, + TEST_DST_SOCKET_ADDRESS, + 0 /* ifindex */, + TEST_SOCKET_COOKIE + ) + getSkDestroyListenerCallback().accept(inetDiagMessage) + + val inOrder = inOrder(mDeps) + inOrder.assertNoDestroyUdpSocket() + inOrder.assertNoSendQuicConnectionClosePayload() + } +} diff --git a/tests/unit/java/com/android/server/connectivityservice/CSDestroySocketTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSDestroySocketTest.kt index bc5be7865c..56d934bf51 100644 --- a/tests/unit/java/com/android/server/connectivityservice/CSDestroySocketTest.kt +++ b/tests/unit/java/com/android/server/connectivityservice/CSDestroySocketTest.kt @@ -155,7 +155,6 @@ class CSDestroySocketTest : CSTest() { @Test fun testReplaceFirewallChain() { val netdEventListener = getRegisteredNetdUnsolicitedEventListener() - val inOrder = inOrder(destroySocketsWrapper) val cellAgent = Agent(nc = cellNc(), lp = cellLp()) cellAgent.connect() @@ -193,8 +192,8 @@ class CSDestroySocketTest : CSTest() { .`when`(bpfNetMaps).getUidNetworkingBlockedReasons(TEST_UID2) cm.replaceFirewallChain(FIREWALL_CHAIN_BACKGROUND, intArrayOf(TEST_UID2)) waitForIdle() - inOrder.verify(destroySocketsWrapper, never()) - .destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(destroySocketsWrapper, never()).destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(quicConnectionCloser, never()).closeQuicConnectionByUids(setOf(TEST_UID)) netdEventListener.onInterfaceClassActivityChanged( true, // isActive @@ -203,8 +202,8 @@ class CSDestroySocketTest : CSTest() { TEST_UID ) waitForIdle() - inOrder.verify(destroySocketsWrapper) - .destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(destroySocketsWrapper).destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(quicConnectionCloser).closeQuicConnectionByUids(setOf(TEST_UID)) cellAgent.disconnect() } @@ -277,8 +276,10 @@ class CSDestroySocketTest : CSTest() { if (expectDestroySockets) { verify(destroySocketsWrapper).destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(quicConnectionCloser).closeQuicConnectionByUids(setOf(TEST_UID)) } else { verify(destroySocketsWrapper, never()).destroyLiveTcpSocketsByOwnerUids(setOf(TEST_UID)) + verify(quicConnectionCloser, never()).closeQuicConnectionByUids(setOf(TEST_UID)) } } diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt index 5c55483ee9..d7ecfd6159 100644 --- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt +++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt @@ -58,6 +58,7 @@ import android.permission.PermissionManager.PermissionResult import android.telephony.SubscriptionManager import android.telephony.TelephonyManager import android.testing.TestableContext +import android.util.SparseArray import androidx.test.platform.app.InstrumentationRegistry import com.android.internal.app.IBatteryStats import com.android.internal.util.test.BroadcastInterceptingContext @@ -72,9 +73,11 @@ import com.android.server.connectivity.InterfaceTracker import com.android.server.connectivity.MulticastRoutingCoordinatorService import com.android.server.connectivity.MultinetworkPolicyTracker import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies +import com.android.server.connectivity.NetworkAgentInfo import com.android.server.connectivity.NetworkRequestStateStatsMetrics import com.android.server.connectivity.PermissionMonitor import com.android.server.connectivity.ProxyTracker +import com.android.server.connectivity.QuicConnectionCloser import com.android.server.connectivity.SatelliteAccessController import com.android.testutils.visibleOnHandlerThread import com.android.testutils.waitForIdle @@ -171,6 +174,7 @@ open class CSTest { it[ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS] = true it[ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS] = true it[ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER] = true + it[ConnectivityFlags.CLOSE_QUIC_CONNECTION] = true } fun setFeatureEnabled(flag: String, enabled: Boolean) = enabledFeatures.set(flag, enabled) @@ -219,6 +223,7 @@ open class CSTest { val multicastRoutingCoordinatorService = mock() val satelliteAccessController = mock() + val quicConnectionCloser = mock() val destroySocketsWrapper = mock() val deps = CSDeps() @@ -412,6 +417,11 @@ open class CSTest { } override fun makeL2capNetworkProvider(context: Context) = null + + override fun makeQuicConnectionCloser( + networkForNetId: SparseArray, + handler: Handler + ): QuicConnectionCloser = quicConnectionCloser } inner class PermDeps : PermissionMonitor.Dependencies() {