diff --git a/framework-t/src/android/net/IIpSecService.aidl b/framework-t/src/android/net/IIpSecService.aidl index 933256a3b4..88ffd0ea9e 100644 --- a/framework-t/src/android/net/IIpSecService.aidl +++ b/framework-t/src/android/net/IIpSecService.aidl @@ -66,6 +66,12 @@ interface IIpSecService IpSecTransformResponse createTransform( in IpSecConfig c, in IBinder binder, in String callingPackage); + void migrateTransform( + int transformId, + in String newSourceAddress, + in String newDestinationAddress, + in String callingPackage); + void deleteTransform(int transformId); void applyTransportModeTransform( diff --git a/framework-t/src/android/net/IpSecManager.java b/framework-t/src/android/net/IpSecManager.java index 9cceac2af3..1c83e09026 100644 --- a/framework-t/src/android/net/IpSecManager.java +++ b/framework-t/src/android/net/IpSecManager.java @@ -37,6 +37,7 @@ import android.util.AndroidException; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; +import com.android.modules.utils.build.SdkLevel; import dalvik.system.CloseGuard; @@ -64,6 +65,24 @@ import java.util.Objects; public class IpSecManager { private static final String TAG = "IpSecManager"; + /** + * Feature flag to declare the kernel support of updating IPsec SAs. + * + *

Feature for {@link #getSystemAvailableFeatures} and {@link #hasSystemFeature}: The device + * has the requisite kernel support for migrating IPsec tunnels to new source/destination + * addresses. + * + *

This feature implies that the device supports XFRM Migration (CONFIG_XFRM_MIGRATE) and has + * the kernel fixes to allow XFRM Migration correctly + * + * @see android.content.pm.PackageManager#FEATURE_IPSEC_TUNNEL_MIGRATION + * @hide + */ + // Redefine this flag here so that IPsec code shipped in a mainline module can build on old + // platforms before FEATURE_IPSEC_TUNNEL_MIGRATION API is released. + public static final String FEATURE_IPSEC_TUNNEL_MIGRATION = + "android.software.ipsec_tunnel_migration"; + /** * Used when applying a transform to direct traffic through an {@link IpSecTransform} * towards the host. @@ -987,6 +1006,59 @@ public class IpSecManager { } } + /** + * Migrate an active Tunnel Mode IPsec Transform to new source/destination addresses. + * + *

Begins the process of migrating a transform and cache the new addresses. To complete the + * migration once started, callers MUST apply the same transform to the appropriate tunnel using + * {@link IpSecManager#applyTunnelModeTransform}. Otherwise, the address update will not be + * committed and the transform will still only process traffic between the current source and + * destination address. One common use case is that the control plane will start the migration + * process and then hand off the transform to the IPsec caller to perform the actual migration + * when the tunnel is ready. + * + *

If this method is called multiple times before {@link + * IpSecManager#applyTunnelModeTransform} is called, when the transform is applied, it will be + * migrated to the addresses from the last call. + * + *

The provided source and destination addresses MUST share the same address family, but they + * can have a different family from the current addresses. + * + *

Transform migration is only supported for tunnel mode transforms. Calling this method on + * other types of transforms will throw an {@code UnsupportedOperationException}. + * + * @see IpSecTunnelInterface#setUnderlyingNetwork + * @param transform a tunnel mode {@link IpSecTransform} + * @param newSourceAddress the new source address + * @param newDestinationAddress the new destination address + * @hide + */ + @RequiresFeature(FEATURE_IPSEC_TUNNEL_MIGRATION) + @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS) + public void startMigration( + @NonNull IpSecTransform transform, + @NonNull InetAddress newSourceAddress, + @NonNull InetAddress newDestinationAddress) { + if (!SdkLevel.isAtLeastU()) { + throw new UnsupportedOperationException( + "Transform migration only supported for Android 14+"); + } + + Objects.requireNonNull(transform, "transform was null"); + Objects.requireNonNull(newSourceAddress, "newSourceAddress was null"); + Objects.requireNonNull(newDestinationAddress, "newDestinationAddress was null"); + + try { + mService.migrateTransform( + transform.getResourceId(), + newSourceAddress.getHostAddress(), + newDestinationAddress.getHostAddress(), + mContext.getOpPackageName()); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + /** * @hide */ diff --git a/service-t/src/com/android/server/IpSecService.java b/service-t/src/com/android/server/IpSecService.java index 6cee08ade8..9e71eb3090 100644 --- a/service-t/src/com/android/server/IpSecService.java +++ b/service-t/src/com/android/server/IpSecService.java @@ -17,6 +17,7 @@ package com.android.server; import static android.Manifest.permission.DUMP; +import static android.net.IpSecManager.FEATURE_IPSEC_TUNNEL_MIGRATION; import static android.net.IpSecManager.INVALID_RESOURCE_ID; import static android.system.OsConstants.AF_INET; import static android.system.OsConstants.AF_INET6; @@ -36,6 +37,7 @@ import android.net.InetAddresses; import android.net.IpSecAlgorithm; import android.net.IpSecConfig; import android.net.IpSecManager; +import android.net.IpSecMigrateInfoParcel; import android.net.IpSecSpiResponse; import android.net.IpSecTransform; import android.net.IpSecTransformResponse; @@ -590,14 +592,19 @@ public class IpSecService extends IIpSecService.Stub { } /** - * Tracks an SA in the kernel, and manages cleanup paths. Once a TransformRecord is - * created, the SpiRecord that originally tracked the SAs will reliquish the - * responsibility of freeing the underlying SA to this class via the mOwnedByTransform flag. + * Tracks an SA in the kernel, and manages cleanup paths. Once a TransformRecord is created, the + * SpiRecord that originally tracked the SAs will reliquish the responsibility of freeing the + * underlying SA to this class via the mOwnedByTransform flag. + * + *

This class is not thread-safe, and expects that that users of this class will ensure + * synchronization and thread safety by holding the IpSecService.this instance lock */ private final class TransformRecord extends OwnedResourceRecord { private final IpSecConfig mConfig; private final SpiRecord mSpi; private final EncapSocketRecord mSocket; + private String mNewSourceAddress = null; + private String mNewDestinationAddress = null; TransformRecord( int resourceId, IpSecConfig config, SpiRecord spi, EncapSocketRecord socket) { @@ -621,6 +628,51 @@ public class IpSecService extends IIpSecService.Stub { return mSocket; } + @GuardedBy("IpSecService.this") + public String getNewSourceAddress() { + return mNewSourceAddress; + } + + @GuardedBy("IpSecService.this") + public String getNewDestinationAddress() { + return mNewDestinationAddress; + } + + private void verifyTunnelModeOrThrow() { + if (mConfig.getMode() != IpSecTransform.MODE_TUNNEL) { + throw new UnsupportedOperationException( + "Migration requested/called on non-tunnel-mode transform"); + } + } + + /** Start migrating this transform to new source and destination addresses */ + @GuardedBy("IpSecService.this") + public void startMigration(String newSourceAddress, String newDestinationAddress) { + verifyTunnelModeOrThrow(); + Objects.requireNonNull(newSourceAddress, "newSourceAddress was null"); + Objects.requireNonNull(newDestinationAddress, "newDestinationAddress was null"); + mNewSourceAddress = newSourceAddress; + mNewDestinationAddress = newDestinationAddress; + } + + /** Finish migration and update addresses. */ + @GuardedBy("IpSecService.this") + public void finishMigration() { + verifyTunnelModeOrThrow(); + mConfig.setSourceAddress(mNewSourceAddress); + mConfig.setDestinationAddress(mNewDestinationAddress); + mNewSourceAddress = null; + mNewDestinationAddress = null; + } + + /** Return if this transform is going to be migrated. */ + @GuardedBy("IpSecService.this") + public boolean isMigrating() { + verifyTunnelModeOrThrow(); + + return mNewSourceAddress != null; + } + /** always guarded by IpSecService#this */ @Override public void freeUnderlyingResources() { @@ -1630,6 +1682,14 @@ public class IpSecService extends IIpSecService.Stub { android.Manifest.permission.MANAGE_IPSEC_TUNNELS, "IpSecService"); } + private void enforceMigrateFeature() { + if (!mContext.getPackageManager().hasSystemFeature(FEATURE_IPSEC_TUNNEL_MIGRATION)) { + throw new UnsupportedOperationException( + "IPsec Tunnel migration requires" + + " PackageManager.FEATURE_IPSEC_TUNNEL_MIGRATION"); + } + } + private void createOrUpdateTransform( IpSecConfig c, int resourceId, SpiRecord spiRecord, EncapSocketRecord socketRecord) throws RemoteException { @@ -1725,6 +1785,45 @@ public class IpSecService extends IIpSecService.Stub { return new IpSecTransformResponse(IpSecManager.Status.OK, resourceId); } + /** + * Migrate an active Tunnel Mode IPsec Transform to new source/destination addresses. + * + *

Begins the process of migrating a transform and cache the new addresses. To complete the + * migration once started, callers MUST apply the same transform to the appropriate tunnel using + * {@link #applyTunnelModeTransform}. Otherwise, the address update will not be committed and + * the transform will still only process traffic between the current source and destination + * address. One common use case is that the control plane will start the migration process and + * then hand off the transform to the IPsec caller to perform the actual migration when the + * tunnel is ready. + * + *

If this method is called multiple times before {@link #applyTunnelModeTransform} is + * called, when the transform is applied, it will be migrated to the addresses from the last + * call. + * + *

The provided source and destination addresses MUST share the same address family, but they + * can have a different family from the current addresses. + * + *

Transform migration is only supported for tunnel mode transforms. Calling this method on + * other types of transforms will throw an {@code UnsupportedOperationException}. + */ + @Override + public synchronized void migrateTransform( + int transformId, + String newSourceAddress, + String newDestinationAddress, + String callingPackage) { + Objects.requireNonNull(newSourceAddress, "newSourceAddress was null"); + Objects.requireNonNull(newDestinationAddress, "newDestinationAddress was null"); + + enforceTunnelFeatureAndPermissions(callingPackage); + enforceMigrateFeature(); + + UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid()); + TransformRecord transformInfo = + userRecord.mTransformRecords.getResourceOrThrow(transformId); + transformInfo.startMigration(newSourceAddress, newDestinationAddress); + } + /** * Delete a transport mode transform that was previously allocated by + registered with the * system server. If this is called on an inactive (or non-existent) transform, it will not @@ -1784,12 +1883,15 @@ public class IpSecService extends IIpSecService.Stub { /** * Apply an active tunnel mode transform to a TunnelInterface, which will apply the IPsec - * security association as a correspondent policy to the provided interface + * security association as a correspondent policy to the provided interface. + * + *

If the transform is migrating, migrate the IPsec security association to new + * source/destination addresses, and mark the migration as finished. */ @Override public synchronized void applyTunnelModeTransform( - int tunnelResourceId, int direction, - int transformResourceId, String callingPackage) throws RemoteException { + int tunnelResourceId, int direction, int transformResourceId, String callingPackage) + throws RemoteException { enforceTunnelFeatureAndPermissions(callingPackage); checkDirection(direction); @@ -1868,6 +1970,32 @@ public class IpSecService extends IIpSecService.Stub { // Update SA with tunnel mark (ikey or okey based on direction) createOrUpdateTransform(c, transformResourceId, spiRecord, socketRecord); + + if (transformInfo.isMigrating()) { + if (!mContext.getPackageManager() + .hasSystemFeature(FEATURE_IPSEC_TUNNEL_MIGRATION)) { + Log.wtf( + TAG, + "Attempted to migrate a transform without" + + " FEATURE_IPSEC_TUNNEL_MIGRATION"); + } + + for (int selAddrFamily : ADDRESS_FAMILIES) { + final IpSecMigrateInfoParcel migrateInfo = + new IpSecMigrateInfoParcel( + Binder.getCallingUid(), + selAddrFamily, + direction, + c.getSourceAddress(), + c.getDestinationAddress(), + transformInfo.getNewSourceAddress(), + transformInfo.getNewDestinationAddress(), + c.getXfrmInterfaceId()); + + mNetd.ipSecMigrate(migrateInfo); + } + transformInfo.finishMigration(); + } } catch (ServiceSpecificException e) { if (e.errorCode == EINVAL) { throw new IllegalArgumentException(e.toString()); diff --git a/tests/unit/java/android/net/IpSecTransformTest.java b/tests/unit/java/android/net/IpSecTransformTest.java index c1bd7190f3..ec59064e3a 100644 --- a/tests/unit/java/android/net/IpSecTransformTest.java +++ b/tests/unit/java/android/net/IpSecTransformTest.java @@ -18,22 +18,92 @@ package android.net; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import android.content.Context; import android.os.Build; +import android.test.mock.MockContext; import androidx.test.filters.SmallTest; +import com.android.server.IpSecService; import com.android.testutils.DevSdkIgnoreRule; import com.android.testutils.DevSdkIgnoreRunner; +import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.net.InetAddress; + /** Unit tests for {@link IpSecTransform}. */ @SmallTest @RunWith(DevSdkIgnoreRunner.class) @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2) public class IpSecTransformTest { + @Rule public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule(); + + private static final int DROID_SPI = 0xD1201D; + private static final int TEST_RESOURCE_ID = 0x1234; + + private static final InetAddress SRC_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.200"); + private static final InetAddress DST_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.201"); + private static final InetAddress SRC_ADDRESS_V6 = + InetAddresses.parseNumericAddress("2001:db8::200"); + private static final InetAddress DST_ADDRESS_V6 = + InetAddresses.parseNumericAddress("2001:db8::201"); + + private MockContext mMockContext; + private IpSecService mMockIpSecService; + private IpSecManager mIpSecManager; + + @Before + public void setUp() throws Exception { + mMockIpSecService = mock(IpSecService.class); + mIpSecManager = new IpSecManager(mock(Context.class) /* unused */, mMockIpSecService); + + // Set up mMockContext since IpSecTransform needs an IpSecManager instance and a non-null + // package name to create transform + mMockContext = + new MockContext() { + @Override + public String getSystemServiceName(Class serviceClass) { + if (serviceClass.equals(IpSecManager.class)) { + return Context.IPSEC_SERVICE; + } + throw new UnsupportedOperationException(); + } + + @Override + public Object getSystemService(String name) { + if (name.equals(Context.IPSEC_SERVICE)) { + return mIpSecManager; + } + throw new UnsupportedOperationException(); + } + + @Override + public String getOpPackageName() { + return "fooPackage"; + } + }; + + final IpSecSpiResponse spiResp = + new IpSecSpiResponse(IpSecManager.Status.OK, TEST_RESOURCE_ID, DROID_SPI); + when(mMockIpSecService.allocateSecurityParameterIndex(any(), anyInt(), any())) + .thenReturn(spiResp); + + final IpSecTransformResponse transformResp = + new IpSecTransformResponse(IpSecManager.Status.OK, TEST_RESOURCE_ID); + when(mMockIpSecService.createTransform(any(), any(), any())).thenReturn(transformResp); + } @Test public void testCreateTransformCopiesConfig() { @@ -64,4 +134,32 @@ public class IpSecTransformTest { assertEquals(config1, config2); } + + private IpSecTransform buildTestTransform() throws Exception { + final IpSecManager.SecurityParameterIndex spi = + mIpSecManager.allocateSecurityParameterIndex(DST_ADDRESS); + return new IpSecTransform.Builder(mMockContext).buildTunnelModeTransform(SRC_ADDRESS, spi); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testStartMigration() throws Exception { + mIpSecManager.startMigration(buildTestTransform(), SRC_ADDRESS_V6, DST_ADDRESS_V6); + verify(mMockIpSecService) + .migrateTransform( + anyInt(), + eq(SRC_ADDRESS_V6.getHostAddress()), + eq(DST_ADDRESS_V6.getHostAddress()), + any()); + } + + @Test + @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.TIRAMISU) + public void testStartMigrationOnSdkBeforeU() throws Exception { + try { + mIpSecManager.startMigration(buildTestTransform(), SRC_ADDRESS_V6, DST_ADDRESS_V6); + fail("Expect to fail since migration is not supported before U"); + } catch (UnsupportedOperationException expected) { + } + } } diff --git a/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java b/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java index 624071a89f..1618a62c81 100644 --- a/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java +++ b/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java @@ -23,6 +23,7 @@ import static android.net.INetd.IF_STATE_UP; import static android.net.IpSecManager.DIRECTION_FWD; import static android.net.IpSecManager.DIRECTION_IN; import static android.net.IpSecManager.DIRECTION_OUT; +import static android.net.IpSecManager.FEATURE_IPSEC_TUNNEL_MIGRATION; import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK; import static android.system.OsConstants.AF_INET; import static android.system.OsConstants.AF_INET6; @@ -30,11 +31,16 @@ import static android.system.OsConstants.AF_INET6; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -49,6 +55,7 @@ import android.net.InterfaceConfigurationParcel; import android.net.IpSecAlgorithm; import android.net.IpSecConfig; import android.net.IpSecManager; +import android.net.IpSecMigrateInfoParcel; import android.net.IpSecSpiResponse; import android.net.IpSecTransform; import android.net.IpSecTransformResponse; @@ -130,6 +137,9 @@ public class IpSecServiceParameterizedTest { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F }; + private static final String NEW_SRC_ADDRESS = "2001:db8:2::1"; + private static final String NEW_DST_ADDRESS = "2001:db8:2::2"; + AppOpsManager mMockAppOps = mock(AppOpsManager.class); ConnectivityManager mMockConnectivityMgr = mock(ConnectivityManager.class); @@ -369,8 +379,8 @@ public class IpSecServiceParameterizedTest { .ipSecAddSecurityAssociation( eq(mUid), eq(config.getMode()), - eq(config.getSourceAddress()), - eq(config.getDestinationAddress()), + eq(mSourceAddr), + eq(mDestinationAddr), eq((config.getNetwork() != null) ? config.getNetwork().netId : 0), eq(TEST_SPI), eq(0), @@ -910,9 +920,60 @@ public class IpSecServiceParameterizedTest { } } + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformOutbound() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(false, DIRECTION_OUT); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformOutboundReleasedSpi() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(true, DIRECTION_OUT); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformInbound() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(false, DIRECTION_IN); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformInboundReleasedSpi() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(true, DIRECTION_IN); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformForward() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(false, DIRECTION_FWD); + } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testApplyAndMigrateTunnelModeTransformForwardReleasedSpi() throws Exception { + verifyApplyAndMigrateTunnelModeTransformCommon(true, DIRECTION_FWD); + } + public void verifyApplyTunnelModeTransformCommon(boolean closeSpiBeforeApply, int direction) throws Exception { - IpSecConfig ipSecConfig = new IpSecConfig(); + verifyApplyTunnelModeTransformCommon( + new IpSecConfig(), closeSpiBeforeApply, false /* isMigrating */, direction); + } + + public void verifyApplyAndMigrateTunnelModeTransformCommon( + boolean closeSpiBeforeApply, int direction) throws Exception { + verifyApplyTunnelModeTransformCommon( + new IpSecConfig(), closeSpiBeforeApply, true /* isMigrating */, direction); + } + + public int verifyApplyTunnelModeTransformCommon( + IpSecConfig ipSecConfig, + boolean closeSpiBeforeApply, + boolean isMigrating, + int direction) + throws Exception { ipSecConfig.setMode(IpSecTransform.MODE_TUNNEL); addDefaultSpisAndRemoteAddrToIpSecConfig(ipSecConfig); addAuthAndCryptToIpSecConfig(ipSecConfig); @@ -928,6 +989,12 @@ public class IpSecServiceParameterizedTest { int transformResourceId = createTransformResp.resourceId; int tunnelResourceId = createTunnelResp.resourceId; + + if (isMigrating) { + mIpSecService.migrateTransform( + transformResourceId, NEW_SRC_ADDRESS, NEW_DST_ADDRESS, BLESSED_PACKAGE); + } + mIpSecService.applyTunnelModeTransform( tunnelResourceId, direction, transformResourceId, BLESSED_PACKAGE); @@ -947,8 +1014,16 @@ public class IpSecServiceParameterizedTest { ipSecConfig.setXfrmInterfaceId(tunnelResourceId); verifyTransformNetdCalledForCreatingSA(ipSecConfig, createTransformResp); - } + if (isMigrating) { + verify(mMockNetd, times(ADDRESS_FAMILIES.length)) + .ipSecMigrate(any(IpSecMigrateInfoParcel.class)); + } else { + verify(mMockNetd, never()).ipSecMigrate(any()); + } + + return tunnelResourceId; + } @Test public void testApplyTunnelModeTransformWithClosedSpi() throws Exception { @@ -1023,7 +1098,7 @@ public class IpSecServiceParameterizedTest { } @Test - public void testFeatureFlagVerification() throws Exception { + public void testFeatureFlagIpSecTunnelsVerification() throws Exception { when(mMockPkgMgr.hasSystemFeature(eq(PackageManager.FEATURE_IPSEC_TUNNELS))) .thenReturn(false); @@ -1035,4 +1110,17 @@ public class IpSecServiceParameterizedTest { } catch (UnsupportedOperationException expected) { } } + + @Test + @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) + public void testFeatureFlagIpSecTunnelMigrationVerification() throws Exception { + when(mMockPkgMgr.hasSystemFeature(eq(FEATURE_IPSEC_TUNNEL_MIGRATION))).thenReturn(false); + + try { + mIpSecService.migrateTransform( + 1 /* transformId */, NEW_SRC_ADDRESS, NEW_DST_ADDRESS, BLESSED_PACKAGE); + fail("Expected UnsupportedOperationException for disabled feature"); + } catch (UnsupportedOperationException expected) { + } + } }