Add experimental support for IPsec tunnel aggregation

This change adds implementation support for IPsec tunnel aggregation,
allowing servers to send downlink traffic over multiple channels. In
cases where a lack of hardware offload of ESP or cases where the
throughput is CPU-bound, multiple SAs may improve throughput.

Bug: 255859065
Test: atest FrameworksVcnTests
Change-Id: I8c8ed14a700fccd36e2783c04bdfd09f649ad3d2
diff --git a/core/java/android/net/vcn/VcnManager.java b/core/java/android/net/vcn/VcnManager.java
index 3a7aea5..70cf973 100644
--- a/core/java/android/net/vcn/VcnManager.java
+++ b/core/java/android/net/vcn/VcnManager.java
@@ -114,13 +114,28 @@
     public static final String VCN_RESTRICTED_TRANSPORTS_INT_ARRAY_KEY =
             "vcn_restricted_transports";
 
+    /**
+     * Key for maximum number of parallel SAs for tunnel aggregation
+     *
+     * <p>If set to a value > 1, multiple tunnels will be set up, and inbound traffic will be
+     * aggregated over the various tunnels.
+     *
+     * <p>Defaults to 1, unless overridden by carrier config
+     *
+     * @hide
+     */
+    @NonNull
+    public static final String VCN_TUNNEL_AGGREGATION_SA_COUNT_MAX_KEY =
+            "vcn_tunnel_aggregation_sa_count_max";
+
     /** List of Carrier Config options to extract from Carrier Config bundles. @hide */
     @NonNull
     public static final String[] VCN_RELATED_CARRIER_CONFIG_KEYS =
             new String[] {
                 VCN_NETWORK_SELECTION_WIFI_ENTRY_RSSI_THRESHOLD_KEY,
                 VCN_NETWORK_SELECTION_WIFI_EXIT_RSSI_THRESHOLD_KEY,
-                VCN_RESTRICTED_TRANSPORTS_INT_ARRAY_KEY
+                VCN_RESTRICTED_TRANSPORTS_INT_ARRAY_KEY,
+                VCN_TUNNEL_AGGREGATION_SA_COUNT_MAX_KEY,
             };
 
     private static final Map<
diff --git a/services/core/java/com/android/server/vcn/VcnGatewayConnection.java b/services/core/java/com/android/server/vcn/VcnGatewayConnection.java
index 3be16a1..739aff7 100644
--- a/services/core/java/com/android/server/vcn/VcnGatewayConnection.java
+++ b/services/core/java/com/android/server/vcn/VcnGatewayConnection.java
@@ -33,6 +33,7 @@
 
 import static com.android.server.VcnManagementService.LOCAL_LOG;
 import static com.android.server.VcnManagementService.VDBG;
+import static com.android.server.vcn.util.PersistableBundleUtils.PersistableBundleWrapper;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -59,6 +60,7 @@
 import android.net.TelephonyNetworkSpecifier;
 import android.net.Uri;
 import android.net.annotations.PolicyDirection;
+import android.net.ipsec.ike.ChildSaProposal;
 import android.net.ipsec.ike.ChildSessionCallback;
 import android.net.ipsec.ike.ChildSessionConfiguration;
 import android.net.ipsec.ike.ChildSessionParams;
@@ -67,11 +69,14 @@
 import android.net.ipsec.ike.IkeSessionConfiguration;
 import android.net.ipsec.ike.IkeSessionConnectionInfo;
 import android.net.ipsec.ike.IkeSessionParams;
+import android.net.ipsec.ike.IkeTrafficSelector;
 import android.net.ipsec.ike.IkeTunnelConnectionParams;
+import android.net.ipsec.ike.TunnelModeChildSessionParams;
 import android.net.ipsec.ike.exceptions.IkeException;
 import android.net.ipsec.ike.exceptions.IkeInternalException;
 import android.net.ipsec.ike.exceptions.IkeProtocolException;
 import android.net.vcn.VcnGatewayConnectionConfig;
+import android.net.vcn.VcnManager;
 import android.net.vcn.VcnTransportInfo;
 import android.net.wifi.WifiInfo;
 import android.os.Handler;
@@ -169,6 +174,9 @@
 public class VcnGatewayConnection extends StateMachine {
     private static final String TAG = VcnGatewayConnection.class.getSimpleName();
 
+    /** Default number of parallel SAs requested */
+    static final int TUNNEL_AGGREGATION_SA_COUNT_MAX_DEFAULT = 1;
+
     // Matches DataConnection.NETWORK_TYPE private constant, and magic string from
     // ConnectivityManager#getNetworkTypeName()
     @VisibleForTesting(visibility = Visibility.PRIVATE)
@@ -1980,6 +1988,22 @@
                             mChildConfig,
                             oldChildConfig,
                             mIkeConnectionInfo);
+
+                    // Create opportunistic child SAs; this allows SA aggregation in the downlink,
+                    // reducing lock/atomic contention in high throughput scenarios. All SAs will
+                    // share the same UDP encap socket (and keepalives) as necessary, and are
+                    // effectively free.
+                    final int parallelTunnelCount =
+                            mDeps.getParallelTunnelCount(mLastSnapshot, mSubscriptionGroup);
+                    logInfo("Parallel tunnel count: " + parallelTunnelCount);
+
+                    for (int i = 0; i < parallelTunnelCount - 1; i++) {
+                        mIkeSession.openChildSession(
+                                buildOpportunisticChildParams(),
+                                new VcnChildSessionCallback(
+                                        mCurrentToken, true /* isOpportunistic */));
+                    }
+
                     break;
                 case EVENT_DISCONNECT_REQUESTED:
                     handleDisconnectRequested((EventDisconnectRequestedInfo) msg.obj);
@@ -2350,15 +2374,44 @@
     @VisibleForTesting(visibility = Visibility.PRIVATE)
     public class VcnChildSessionCallback implements ChildSessionCallback {
         private final int mToken;
+        private final boolean mIsOpportunistic;
+
+        private boolean mIsChildOpened = false;
 
         VcnChildSessionCallback(int token) {
+            this(token, false /* isOpportunistic */);
+        }
+
+        /**
+         * Creates a ChildSessionCallback
+         *
+         * <p>If configured as opportunistic, transforms will not report initial startup, or
+         * associated startup failures. This serves the dual purposes of ensuring that if the server
+         * does not support connection multiplexing, new child SA negotiations will be ignored, and
+         * at the same time, will notify the VCN session if a successfully negotiated opportunistic
+         * child SA is subsequently torn down, which could impact uplink traffic if the SA in use
+         * for outbound/uplink traffic is this opportunistic SA.
+         *
+         * <p>While inbound SAs can be used in parallel, the IPsec stack explicitly selects the last
+         * applied outbound transform for outbound traffic. This means that unlike inbound traffic,
+         * outbound does not benefit from these parallel SAs in the same manner.
+         */
+        VcnChildSessionCallback(int token, boolean isOpportunistic) {
             mToken = token;
+            mIsOpportunistic = isOpportunistic;
         }
 
         /** Internal proxy method for injecting of mocked ChildSessionConfiguration */
         @VisibleForTesting(visibility = Visibility.PRIVATE)
         void onOpened(@NonNull VcnChildSessionConfiguration childConfig) {
             logDbg("ChildOpened for token " + mToken);
+
+            if (mIsOpportunistic) {
+                logDbg("ChildOpened for opportunistic child; suppressing event message");
+                mIsChildOpened = true;
+                return;
+            }
+
             childOpened(mToken, childConfig);
         }
 
@@ -2370,12 +2423,24 @@
         @Override
         public void onClosed() {
             logDbg("ChildClosed for token " + mToken);
+
+            if (mIsOpportunistic && !mIsChildOpened) {
+                logDbg("ChildClosed for unopened opportunistic child; ignoring");
+                return;
+            }
+
             sessionLost(mToken, null);
         }
 
         @Override
         public void onClosedExceptionally(@NonNull IkeException exception) {
             logInfo("ChildClosedExceptionally for token " + mToken, exception);
+
+            if (mIsOpportunistic && !mIsChildOpened) {
+                logInfo("ChildClosedExceptionally for unopened opportunistic child; ignoring");
+                return;
+            }
+
             sessionLost(mToken, exception);
         }
 
@@ -2580,6 +2645,30 @@
         return mConnectionConfig.getTunnelConnectionParams().getTunnelModeChildSessionParams();
     }
 
+    private ChildSessionParams buildOpportunisticChildParams() {
+        final ChildSessionParams baseParams =
+                mConnectionConfig.getTunnelConnectionParams().getTunnelModeChildSessionParams();
+
+        final TunnelModeChildSessionParams.Builder builder =
+                new TunnelModeChildSessionParams.Builder();
+        for (ChildSaProposal proposal : baseParams.getChildSaProposals()) {
+            builder.addChildSaProposal(proposal);
+        }
+
+        for (IkeTrafficSelector inboundSelector : baseParams.getInboundTrafficSelectors()) {
+            builder.addInboundTrafficSelectors(inboundSelector);
+        }
+
+        for (IkeTrafficSelector outboundSelector : baseParams.getOutboundTrafficSelectors()) {
+            builder.addOutboundTrafficSelectors(outboundSelector);
+        }
+
+        builder.setLifetimeSeconds(
+                baseParams.getHardLifetimeSeconds(), baseParams.getSoftLifetimeSeconds());
+
+        return builder.build();
+    }
+
     @VisibleForTesting(visibility = Visibility.PRIVATE)
     VcnIkeSession buildIkeSession(@NonNull Network network) {
         final int token = ++mCurrentToken;
@@ -2680,6 +2769,23 @@
                 return 0;
             }
         }
+
+        /** Gets the max number of parallel tunnels allowed for tunnel aggregation. */
+        public int getParallelTunnelCount(
+                TelephonySubscriptionSnapshot snapshot, ParcelUuid subGrp) {
+            PersistableBundleWrapper carrierConfig = snapshot.getCarrierConfigForSubGrp(subGrp);
+            int result = TUNNEL_AGGREGATION_SA_COUNT_MAX_DEFAULT;
+
+            if (carrierConfig != null) {
+                result =
+                        carrierConfig.getInt(
+                                VcnManager.VCN_TUNNEL_AGGREGATION_SA_COUNT_MAX_KEY,
+                                TUNNEL_AGGREGATION_SA_COUNT_MAX_DEFAULT);
+            }
+
+            // Guard against tunnel count < 1
+            return Math.max(1, result);
+        }
     }
 
     /**
diff --git a/services/core/java/com/android/server/vcn/util/PersistableBundleUtils.java b/services/core/java/com/android/server/vcn/util/PersistableBundleUtils.java
index d22ec0a..d6761a2 100644
--- a/services/core/java/com/android/server/vcn/util/PersistableBundleUtils.java
+++ b/services/core/java/com/android/server/vcn/util/PersistableBundleUtils.java
@@ -573,5 +573,10 @@
 
             return isEqual(mBundle, other.mBundle);
         }
+
+        @Override
+        public String toString() {
+            return mBundle.toString();
+        }
     }
 }
diff --git a/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionConnectedStateTest.java b/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionConnectedStateTest.java
index 1c21a06..aad7a5e 100644
--- a/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionConnectedStateTest.java
+++ b/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionConnectedStateTest.java
@@ -59,6 +59,7 @@
 import android.net.NetworkCapabilities;
 import android.net.ipsec.ike.ChildSaProposal;
 import android.net.ipsec.ike.IkeSessionConnectionInfo;
+import android.net.ipsec.ike.TunnelModeChildSessionParams;
 import android.net.ipsec.ike.exceptions.IkeException;
 import android.net.ipsec.ike.exceptions.IkeInternalException;
 import android.net.ipsec.ike.exceptions.IkeProtocolException;
@@ -70,6 +71,7 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.server.vcn.VcnGatewayConnection.VcnChildSessionCallback;
 import com.android.server.vcn.routeselection.UnderlyingNetworkRecord;
 import com.android.server.vcn.util.MtuUtils;
 
@@ -90,6 +92,8 @@
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class VcnGatewayConnectionConnectedStateTest extends VcnGatewayConnectionTestBase {
+    private static final int PARALLEL_SA_COUNT = 4;
+
     private VcnIkeSession mIkeSession;
     private VcnNetworkAgent mNetworkAgent;
     private Network mVcnNetwork;
@@ -227,16 +231,29 @@
     private void verifyVcnTransformsApplied(
             VcnGatewayConnection vcnGatewayConnection, boolean expectForwardTransform)
             throws Exception {
+        verifyVcnTransformsApplied(
+                vcnGatewayConnection,
+                expectForwardTransform,
+                Collections.singletonList(getChildSessionCallback()));
+    }
+
+    private void verifyVcnTransformsApplied(
+            VcnGatewayConnection vcnGatewayConnection,
+            boolean expectForwardTransform,
+            List<VcnChildSessionCallback> callbacks)
+            throws Exception {
         for (int direction : new int[] {DIRECTION_IN, DIRECTION_OUT}) {
-            getChildSessionCallback().onIpSecTransformCreated(makeDummyIpSecTransform(), direction);
+            for (VcnChildSessionCallback cb : callbacks) {
+                cb.onIpSecTransformCreated(makeDummyIpSecTransform(), direction);
+            }
             mTestLooper.dispatchAll();
 
-            verify(mIpSecSvc)
+            verify(mIpSecSvc, times(callbacks.size()))
                     .applyTunnelModeTransform(
                             eq(TEST_IPSEC_TUNNEL_RESOURCE_ID), eq(direction), anyInt(), any());
         }
 
-        verify(mIpSecSvc, expectForwardTransform ? times(1) : never())
+        verify(mIpSecSvc, expectForwardTransform ? times(callbacks.size()) : never())
                 .applyTunnelModeTransform(
                         eq(TEST_IPSEC_TUNNEL_RESOURCE_ID), eq(DIRECTION_FWD), anyInt(), any());
 
@@ -416,6 +433,89 @@
         verifySafeModeStateAndCallbackFired(1 /* invocationCount */, false /* isInSafeMode */);
     }
 
+    private List<VcnChildSessionCallback> openChildAndVerifyParallelSasRequested()
+            throws Exception {
+        doReturn(PARALLEL_SA_COUNT)
+                .when(mDeps)
+                .getParallelTunnelCount(eq(TEST_SUBSCRIPTION_SNAPSHOT), eq(TEST_SUB_GRP));
+
+        // Verify scheduled but not canceled when entering ConnectedState
+        verifySafeModeTimeoutAlarmAndGetCallback(false /* expectCanceled */);
+        triggerChildOpened();
+        mTestLooper.dispatchAll();
+
+        // Verify new child sessions requested
+        final ArgumentCaptor<VcnChildSessionCallback> captor =
+                ArgumentCaptor.forClass(VcnChildSessionCallback.class);
+        verify(mIkeSession, times(PARALLEL_SA_COUNT - 1))
+                .openChildSession(any(TunnelModeChildSessionParams.class), captor.capture());
+
+        return captor.getAllValues();
+    }
+
+    private List<VcnChildSessionCallback> verifyChildOpenedRequestsAndAppliesParallelSas()
+            throws Exception {
+        List<VcnChildSessionCallback> callbacks = openChildAndVerifyParallelSasRequested();
+
+        verifyVcnTransformsApplied(mGatewayConnection, false, callbacks);
+
+        // Mock IKE calling of onOpened()
+        for (VcnChildSessionCallback cb : callbacks) {
+            cb.onOpened(mock(VcnChildSessionConfiguration.class));
+        }
+        mTestLooper.dispatchAll();
+
+        assertEquals(mGatewayConnection.mConnectedState, mGatewayConnection.getCurrentState());
+        return callbacks;
+    }
+
+    @Test
+    public void testChildOpenedWithParallelSas() throws Exception {
+        verifyChildOpenedRequestsAndAppliesParallelSas();
+    }
+
+    @Test
+    public void testOpportunisticSa_ignoresPreOpenFailures() throws Exception {
+        List<VcnChildSessionCallback> callbacks = openChildAndVerifyParallelSasRequested();
+
+        for (VcnChildSessionCallback cb : callbacks) {
+            cb.onClosed();
+            cb.onClosedExceptionally(mock(IkeException.class));
+        }
+        mTestLooper.dispatchAll();
+
+        assertEquals(mGatewayConnection.mConnectedState, mGatewayConnection.getCurrentState());
+        assertEquals(mIkeConnectionInfo, mGatewayConnection.getIkeConnectionInfo());
+    }
+
+    private void verifyPostOpenFailuresCloseSession(boolean shouldCloseWithException)
+            throws Exception {
+        List<VcnChildSessionCallback> callbacks = verifyChildOpenedRequestsAndAppliesParallelSas();
+
+        for (VcnChildSessionCallback cb : callbacks) {
+            if (shouldCloseWithException) {
+                cb.onClosed();
+            } else {
+                cb.onClosedExceptionally(mock(IkeException.class));
+            }
+        }
+        mTestLooper.dispatchAll();
+
+        assertEquals(mGatewayConnection.mDisconnectingState, mGatewayConnection.getCurrentState());
+        verify(mIkeSession).close();
+    }
+
+    @Test
+    public void testOpportunisticSa_handlesPostOpenFailures_onClosed() throws Exception {
+        verifyPostOpenFailuresCloseSession(false /* shouldCloseWithException */);
+    }
+
+    @Test
+    public void testOpportunisticSa_handlesPostOpenFailures_onClosedExceptionally()
+            throws Exception {
+        verifyPostOpenFailuresCloseSession(true /* shouldCloseWithException */);
+    }
+
     @Test
     public void testInternalAndDnsAddressesChanged() throws Exception {
         final List<LinkAddress> startingInternalAddrs =
diff --git a/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionTestBase.java b/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionTestBase.java
index 7bafd24..1a4149a 100644
--- a/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionTestBase.java
+++ b/tests/vcn/java/com/android/server/vcn/VcnGatewayConnectionTestBase.java
@@ -209,6 +209,9 @@
         doReturn(mWakeLock)
                 .when(mDeps)
                 .newWakeLock(eq(mContext), eq(PowerManager.PARTIAL_WAKE_LOCK), any());
+        doReturn(1)
+                .when(mDeps)
+                .getParallelTunnelCount(eq(TEST_SUBSCRIPTION_SNAPSHOT), eq(TEST_SUB_GRP));
 
         setUpWakeupMessage(mTeardownTimeoutAlarm, VcnGatewayConnection.TEARDOWN_TIMEOUT_ALARM);
         setUpWakeupMessage(mDisconnectRequestAlarm, VcnGatewayConnection.DISCONNECT_REQUEST_ALARM);