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() {