Always-on VPN.

Adds support for always-on VPN profiles, also called "lockdown." When
enabled, LockdownVpnTracker manages the netd firewall to prevent
unencrypted traffic from leaving the device. It creates narrow rules
to only allow traffic to the selected VPN server. When an egress
network becomes available, LockdownVpnTracker will try bringing up
the VPN connection, and will reconnect if disconnected.

ConnectivityService augments any NetworkInfo based on the lockdown
VPN status to help apps wait until the VPN is connected.

This feature requires that VPN profiles use an IP address for both
VPN server and DNS. It also blocks non-default APN access when
enabled. Waits for USER_PRESENT after boot to check KeyStore status.

Bug: 5756357
Change-Id: If615f206b1634000d78a8350a17e88bfcac8e0d0
diff --git a/core/java/android/app/NotificationManager.java b/core/java/android/app/NotificationManager.java
index bf83f5e..69c20b0 100644
--- a/core/java/android/app/NotificationManager.java
+++ b/core/java/android/app/NotificationManager.java
@@ -17,10 +17,9 @@
 package android.app;
 
 import android.content.Context;
-import android.os.Binder;
-import android.os.RemoteException;
 import android.os.Handler;
 import android.os.IBinder;
+import android.os.RemoteException;
 import android.os.ServiceManager;
 import android.util.Log;
 
@@ -88,6 +87,11 @@
         mContext = context;
     }
 
+    /** {@hide} */
+    public static NotificationManager from(Context context) {
+        return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+    }
+
     /**
      * Post a notification to be shown in the status bar. If a notification with
      * the same id has already been posted by your application and has not yet been canceled, it
diff --git a/core/java/android/net/ConnectivityManager.java b/core/java/android/net/ConnectivityManager.java
index d30ef04..60bf4d6 100644
--- a/core/java/android/net/ConnectivityManager.java
+++ b/core/java/android/net/ConnectivityManager.java
@@ -912,4 +912,13 @@
             return false;
         }
     }
+
+    /** {@hide} */
+    public boolean updateLockdownVpn() {
+        try {
+            return mService.updateLockdownVpn();
+        } catch (RemoteException e) {
+            return false;
+        }
+    }
 }
diff --git a/core/java/android/net/IConnectivityManager.aidl b/core/java/android/net/IConnectivityManager.aidl
index dea25dd..3614045 100644
--- a/core/java/android/net/IConnectivityManager.aidl
+++ b/core/java/android/net/IConnectivityManager.aidl
@@ -122,4 +122,6 @@
     void startLegacyVpn(in VpnProfile profile);
 
     LegacyVpnInfo getLegacyVpnInfo();
+
+    boolean updateLockdownVpn();
 }
diff --git a/core/java/com/android/internal/net/VpnProfile.java b/core/java/com/android/internal/net/VpnProfile.java
index d6c5702..7287327 100644
--- a/core/java/com/android/internal/net/VpnProfile.java
+++ b/core/java/com/android/internal/net/VpnProfile.java
@@ -18,7 +18,10 @@
 
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
 
+import java.net.InetAddress;
 import java.nio.charset.Charsets;
 
 /**
@@ -31,6 +34,8 @@
  * @hide
  */
 public class VpnProfile implements Cloneable, Parcelable {
+    private static final String TAG = "VpnProfile";
+
     // Match these constants with R.array.vpn_types.
     public static final int TYPE_PPTP = 0;
     public static final int TYPE_L2TP_IPSEC_PSK = 1;
@@ -124,6 +129,32 @@
         return builder.toString().getBytes(Charsets.UTF_8);
     }
 
+    /**
+     * Test if profile is valid for lockdown, which requires IPv4 address for
+     * both server and DNS. Server hostnames would require using DNS before
+     * connection.
+     */
+    public boolean isValidLockdownProfile() {
+        try {
+            InetAddress.parseNumericAddress(server);
+
+            for (String dnsServer : dnsServers.split(" +")) {
+                InetAddress.parseNumericAddress(this.dnsServers);
+            }
+            if (TextUtils.isEmpty(dnsServers)) {
+                Log.w(TAG, "DNS required");
+                return false;
+            }
+
+            // Everything checked out above
+            return true;
+
+        } catch (IllegalArgumentException e) {
+            Log.w(TAG, "Invalid address", e);
+            return false;
+        }
+    }
+
     @Override
     public void writeToParcel(Parcel out, int flags) {
         out.writeString(key);
diff --git a/core/res/res/values/public.xml b/core/res/res/values/public.xml
index e17a05d..d761980 100644
--- a/core/res/res/values/public.xml
+++ b/core/res/res/values/public.xml
@@ -1442,6 +1442,7 @@
   <java-symbol type="drawable" name="stat_sys_tether_usb" />
   <java-symbol type="drawable" name="stat_sys_throttled" />
   <java-symbol type="drawable" name="vpn_connected" />
+  <java-symbol type="drawable" name="vpn_disconnected" />
   <java-symbol type="id" name="ask_checkbox" />
   <java-symbol type="id" name="compat_checkbox" />
   <java-symbol type="id" name="original_app_icon" />
@@ -1557,6 +1558,10 @@
   <java-symbol type="string" name="vpn_text_long" />
   <java-symbol type="string" name="vpn_title" />
   <java-symbol type="string" name="vpn_title_long" />
+  <java-symbol type="string" name="vpn_lockdown_connecting" />
+  <java-symbol type="string" name="vpn_lockdown_connected" />
+  <java-symbol type="string" name="vpn_lockdown_error" />
+  <java-symbol type="string" name="vpn_lockdown_reset" />
   <java-symbol type="string" name="wallpaper_binding_label" />
   <java-symbol type="style" name="Theme.Dialog.AppError" />
   <java-symbol type="style" name="Theme.Toast" />
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index b7ad1e9..e77dde7 100755
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -3272,6 +3272,15 @@
     <!-- The text of the notification when VPN is active with a session name. -->
     <string name="vpn_text_long">Connected to <xliff:g id="session" example="office">%s</xliff:g>. Touch to manage the network.</string>
 
+    <!-- Notification title when connecting to lockdown VPN. -->
+    <string name="vpn_lockdown_connecting">Always-on VPN connecting\u2026</string>
+    <!-- Notification title when connected to lockdown VPN. -->
+    <string name="vpn_lockdown_connected">Always-on VPN connected</string>
+    <!-- Notification title when error connecting to lockdown VPN. -->
+    <string name="vpn_lockdown_error">Always-on VPN error</string>
+    <!-- Notification body that indicates user can touch to cycle lockdown VPN connection. -->
+    <string name="vpn_lockdown_reset">Touch to reset connection</string>
+
     <!-- Localized strings for WebView -->
     <!-- Label for button in a WebView that will open a chooser to choose a file to upload -->
     <string name="upload_file">Choose file</string>
diff --git a/keystore/java/android/security/Credentials.java b/keystore/java/android/security/Credentials.java
index f6bf432..b233ff6 100644
--- a/keystore/java/android/security/Credentials.java
+++ b/keystore/java/android/security/Credentials.java
@@ -61,6 +61,9 @@
     /** Key prefix for WIFI. */
     public static final String WIFI = "WIFI_";
 
+    /** Key containing suffix of lockdown VPN profile. */
+    public static final String LOCKDOWN_VPN = "LOCKDOWN_VPN";
+
     /** Data type for public keys. */
     public static final String EXTRA_PUBLIC_KEY = "KEY";
 
diff --git a/services/java/com/android/server/ConnectivityService.java b/services/java/com/android/server/ConnectivityService.java
index 3c2ab16..776a1a4 100644
--- a/services/java/com/android/server/ConnectivityService.java
+++ b/services/java/com/android/server/ConnectivityService.java
@@ -16,6 +16,7 @@
 
 package com.android.server;
 
+import static android.Manifest.permission.CONNECTIVITY_INTERNAL;
 import static android.Manifest.permission.MANAGE_NETWORK_POLICY;
 import static android.Manifest.permission.RECEIVE_DATA_ACTIVITY_CHANGE;
 import static android.net.ConnectivityManager.CONNECTIVITY_ACTION;
@@ -31,13 +32,13 @@
 import static android.net.NetworkPolicyManager.RULE_ALLOW_ALL;
 import static android.net.NetworkPolicyManager.RULE_REJECT_METERED;
 
-import android.app.NotificationManager;
-import android.app.PendingIntent;
 import android.bluetooth.BluetoothTetheringDataTracker;
+import android.content.BroadcastReceiver;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.ContextWrapper;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.PackageManager;
 import android.content.res.Resources;
 import android.database.ContentObserver;
@@ -80,6 +81,7 @@
 import android.os.SystemClock;
 import android.os.SystemProperties;
 import android.provider.Settings;
+import android.security.Credentials;
 import android.security.KeyStore;
 import android.text.TextUtils;
 import android.util.EventLog;
@@ -91,11 +93,11 @@
 import com.android.internal.net.VpnProfile;
 import com.android.internal.telephony.Phone;
 import com.android.internal.telephony.PhoneConstants;
-import com.android.internal.util.Preconditions;
 import com.android.server.am.BatteryStatsService;
 import com.android.server.connectivity.Tethering;
 import com.android.server.connectivity.Vpn;
 import com.android.server.net.BaseNetworkObserver;
+import com.android.server.net.LockdownVpnTracker;
 import com.google.android.collect.Lists;
 import com.google.android.collect.Sets;
 
@@ -142,11 +144,14 @@
     private Tethering mTethering;
     private boolean mTetheringConfigValid = false;
 
-    private final KeyStore mKeyStore;
+    private KeyStore mKeyStore;
 
     private Vpn mVpn;
     private VpnCallback mVpnCallback = new VpnCallback();
 
+    private boolean mLockdownEnabled;
+    private LockdownVpnTracker mLockdownTracker;
+
     /** Lock around {@link #mUidRules} and {@link #mMeteredIfaces}. */
     private Object mRulesLock = new Object();
     /** Currently active network rules by UID. */
@@ -276,6 +281,8 @@
      */
     private static final int EVENT_SET_POLICY_DATA_ENABLE = 13;
 
+    private static final int EVENT_VPN_STATE_CHANGED = 14;
+
     /** Handler used for internal events. */
     private InternalHandler mHandler;
     /** Handler used for incoming {@link NetworkStateTracker} events. */
@@ -786,6 +793,9 @@
             info = new NetworkInfo(info);
             info.setDetailedState(DetailedState.BLOCKED, null, null);
         }
+        if (mLockdownTracker != null) {
+            info = mLockdownTracker.augmentNetworkInfo(info);
+        }
         return info;
     }
 
@@ -803,6 +813,17 @@
         return getNetworkInfo(mActiveDefaultNetwork, uid);
     }
 
+    public NetworkInfo getActiveNetworkInfoUnfiltered() {
+        enforceAccessPermission();
+        if (isNetworkTypeValid(mActiveDefaultNetwork)) {
+            final NetworkStateTracker tracker = mNetTrackers[mActiveDefaultNetwork];
+            if (tracker != null) {
+                return tracker.getNetworkInfo();
+            }
+        }
+        return null;
+    }
+
     @Override
     public NetworkInfo getActiveNetworkInfoForUid(int uid) {
         enforceConnectivityInternalPermission();
@@ -1062,6 +1083,12 @@
             // TODO - move this into individual networktrackers
             int usedNetworkType = convertFeatureToNetworkType(networkType, feature);
 
+            if (mLockdownEnabled) {
+                // Since carrier APNs usually aren't available from VPN
+                // endpoint, mark them as unavailable.
+                return PhoneConstants.APN_TYPE_NOT_AVAILABLE;
+            }
+
             if (mProtectedNetworks.contains(usedNetworkType)) {
                 enforceConnectivityInternalPermission();
             }
@@ -1769,7 +1796,7 @@
         }
     }
 
-    private void sendConnectedBroadcast(NetworkInfo info) {
+    public void sendConnectedBroadcast(NetworkInfo info) {
         sendGeneralBroadcast(info, CONNECTIVITY_ACTION_IMMEDIATE);
         sendGeneralBroadcast(info, CONNECTIVITY_ACTION);
     }
@@ -1784,6 +1811,10 @@
     }
 
     private Intent makeGeneralIntent(NetworkInfo info, String bcastType) {
+        if (mLockdownTracker != null) {
+            info = mLockdownTracker.augmentNetworkInfo(info);
+        }
+
         Intent intent = new Intent(bcastType);
         intent.putExtra(ConnectivityManager.EXTRA_NETWORK_INFO, info);
         intent.putExtra(ConnectivityManager.EXTRA_NETWORK_TYPE, info.getType());
@@ -1915,8 +1946,26 @@
         }
         // load the global proxy at startup
         mHandler.sendMessage(mHandler.obtainMessage(EVENT_APPLY_GLOBAL_HTTP_PROXY));
+
+        // Try bringing up tracker, but if KeyStore isn't ready yet, wait
+        // for user to unlock device.
+        if (!updateLockdownVpn()) {
+            final IntentFilter filter = new IntentFilter(Intent.ACTION_USER_PRESENT);
+            mContext.registerReceiver(mUserPresentReceiver, filter);
+        }
     }
 
+    private BroadcastReceiver mUserPresentReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // Try creating lockdown tracker, since user present usually means
+            // unlocked keystore.
+            if (updateLockdownVpn()) {
+                mContext.unregisterReceiver(this);
+            }
+        }
+    };
+
     private void handleConnect(NetworkInfo info) {
         final int type = info.getType();
 
@@ -2595,6 +2644,9 @@
                     } else if (state == NetworkInfo.State.CONNECTED) {
                         handleConnect(info);
                     }
+                    if (mLockdownTracker != null) {
+                        mLockdownTracker.onNetworkInfoChanged(info);
+                    }
                     break;
                 case NetworkStateTracker.EVENT_CONFIGURATION_CHANGED:
                     info = (NetworkInfo) msg.obj;
@@ -2692,6 +2744,13 @@
                     final int networkType = msg.arg1;
                     final boolean enabled = msg.arg2 == ENABLED;
                     handleSetPolicyDataEnable(networkType, enabled);
+                    break;
+                }
+                case EVENT_VPN_STATE_CHANGED: {
+                    if (mLockdownTracker != null) {
+                        mLockdownTracker.onVpnStateChanged((NetworkInfo) msg.obj);
+                    }
+                    break;
                 }
             }
         }
@@ -3090,6 +3149,7 @@
      */
     @Override
     public boolean protectVpn(ParcelFileDescriptor socket) {
+        throwIfLockdownEnabled();
         try {
             int type = mActiveDefaultNetwork;
             if (ConnectivityManager.isNetworkTypeValid(type)) {
@@ -3116,6 +3176,7 @@
      */
     @Override
     public boolean prepareVpn(String oldPackage, String newPackage) {
+        throwIfLockdownEnabled();
         return mVpn.prepare(oldPackage, newPackage);
     }
 
@@ -3128,6 +3189,7 @@
      */
     @Override
     public ParcelFileDescriptor establishVpn(VpnConfig config) {
+        throwIfLockdownEnabled();
         return mVpn.establish(config);
     }
 
@@ -3137,6 +3199,7 @@
      */
     @Override
     public void startLegacyVpn(VpnProfile profile) {
+        throwIfLockdownEnabled();
         final LinkProperties egress = getActiveLinkProperties();
         if (egress == null) {
             throw new IllegalStateException("Missing active network connection");
@@ -3152,6 +3215,7 @@
      */
     @Override
     public LegacyVpnInfo getLegacyVpnInfo() {
+        throwIfLockdownEnabled();
         return mVpn.getLegacyVpnInfo();
     }
 
@@ -3170,8 +3234,7 @@
         }
 
         public void onStateChanged(NetworkInfo info) {
-            // TODO: if connected, release delayed broadcast
-            // TODO: if disconnected, consider kicking off reconnect
+            mHandler.obtainMessage(EVENT_VPN_STATE_CHANGED, info).sendToTarget();
         }
 
         public void override(List<String> dnsServers, List<String> searchDomains) {
@@ -3240,4 +3303,58 @@
             }
         }
     }
+
+    @Override
+    public boolean updateLockdownVpn() {
+        mContext.enforceCallingOrSelfPermission(CONNECTIVITY_INTERNAL, TAG);
+
+        // Tear down existing lockdown if profile was removed
+        mLockdownEnabled = LockdownVpnTracker.isEnabled();
+        if (mLockdownEnabled) {
+            if (mKeyStore.state() != KeyStore.State.UNLOCKED) {
+                Slog.w(TAG, "KeyStore locked; unable to create LockdownTracker");
+                return false;
+            }
+
+            final String profileName = new String(mKeyStore.get(Credentials.LOCKDOWN_VPN));
+            final VpnProfile profile = VpnProfile.decode(
+                    profileName, mKeyStore.get(Credentials.VPN + profileName));
+            setLockdownTracker(new LockdownVpnTracker(mContext, mNetd, this, mVpn, profile));
+        } else {
+            setLockdownTracker(null);
+        }
+
+        return true;
+    }
+
+    /**
+     * Internally set new {@link LockdownVpnTracker}, shutting down any existing
+     * {@link LockdownVpnTracker}. Can be {@code null} to disable lockdown.
+     */
+    private void setLockdownTracker(LockdownVpnTracker tracker) {
+        // Shutdown any existing tracker
+        final LockdownVpnTracker existing = mLockdownTracker;
+        mLockdownTracker = null;
+        if (existing != null) {
+            existing.shutdown();
+        }
+
+        try {
+            if (tracker != null) {
+                mNetd.setFirewallEnabled(true);
+                mLockdownTracker = tracker;
+                mLockdownTracker.init();
+            } else {
+                mNetd.setFirewallEnabled(false);
+            }
+        } catch (RemoteException e) {
+            // ignored; NMS lives inside system_server
+        }
+    }
+
+    private void throwIfLockdownEnabled() {
+        if (mLockdownEnabled) {
+            throw new IllegalStateException("Unavailable in lockdown mode");
+        }
+    }
 }
diff --git a/services/java/com/android/server/NetworkManagementService.java b/services/java/com/android/server/NetworkManagementService.java
index fbd45a0..efa16af 100644
--- a/services/java/com/android/server/NetworkManagementService.java
+++ b/services/java/com/android/server/NetworkManagementService.java
@@ -58,6 +58,7 @@
 import com.android.internal.net.NetworkStatsFactory;
 import com.android.internal.util.Preconditions;
 import com.android.server.NativeDaemonConnector.Command;
+import com.android.server.net.LockdownVpnTracker;
 import com.google.android.collect.Maps;
 
 import java.io.BufferedReader;
@@ -370,7 +371,7 @@
         }
 
         // TODO: Push any existing firewall state
-        setFirewallEnabled(mFirewallEnabled);
+        setFirewallEnabled(mFirewallEnabled || LockdownVpnTracker.isEnabled());
     }
 
     //
diff --git a/services/java/com/android/server/connectivity/Vpn.java b/services/java/com/android/server/connectivity/Vpn.java
index d96bd0d..b3cbb84 100644
--- a/services/java/com/android/server/connectivity/Vpn.java
+++ b/services/java/com/android/server/connectivity/Vpn.java
@@ -90,6 +90,7 @@
     private Connection mConnection;
     private LegacyVpnRunner mLegacyVpnRunner;
     private PendingIntent mStatusIntent;
+    private boolean mEnableNotif = true;
 
     public Vpn(Context context, VpnCallback callback, INetworkManagementService netService) {
         // TODO: create dedicated TYPE_VPN network type
@@ -104,6 +105,10 @@
         }
     }
 
+    public void setEnableNotifications(boolean enableNotif) {
+        mEnableNotif = enableNotif;
+    }
+
     @Override
     protected void startMonitoringInternal() {
         // Ignored; events are sent through callbacks for now
@@ -394,6 +399,7 @@
     }
 
     private void showNotification(VpnConfig config, String label, Bitmap icon) {
+        if (!mEnableNotif) return;
         mStatusIntent = VpnConfig.getIntentForStatusPanel(mContext, config);
 
         NotificationManager nm = (NotificationManager)
@@ -420,6 +426,7 @@
     }
 
     private void hideNotification() {
+        if (!mEnableNotif) return;
         mStatusIntent = null;
 
         NotificationManager nm = (NotificationManager)
@@ -598,6 +605,14 @@
         return info;
     }
 
+    public VpnConfig getLegacyVpnConfig() {
+        if (mLegacyVpnRunner != null) {
+            return mLegacyVpnRunner.mConfig;
+        } else {
+            return null;
+        }
+    }
+
     /**
      * Bringing up a VPN connection takes time, and that is all this thread
      * does. Here we have plenty of time. The only thing we need to take
diff --git a/services/java/com/android/server/net/LockdownVpnTracker.java b/services/java/com/android/server/net/LockdownVpnTracker.java
new file mode 100644
index 0000000..541650e
--- /dev/null
+++ b/services/java/com/android/server/net/LockdownVpnTracker.java
@@ -0,0 +1,271 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.net;
+
+import static android.Manifest.permission.CONNECTIVITY_INTERNAL;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.LinkProperties;
+import android.net.NetworkInfo;
+import android.net.NetworkInfo.DetailedState;
+import android.net.NetworkInfo.State;
+import android.os.INetworkManagementService;
+import android.os.RemoteException;
+import android.security.Credentials;
+import android.security.KeyStore;
+import android.text.TextUtils;
+import android.util.Slog;
+
+import com.android.internal.R;
+import com.android.internal.net.VpnConfig;
+import com.android.internal.net.VpnProfile;
+import com.android.internal.util.Preconditions;
+import com.android.server.ConnectivityService;
+import com.android.server.connectivity.Vpn;
+
+/**
+ * State tracker for lockdown mode. Watches for normal {@link NetworkInfo} to be
+ * connected and kicks off VPN connection, managing any required {@code netd}
+ * firewall rules.
+ */
+public class LockdownVpnTracker {
+    private static final String TAG = "LockdownVpnTracker";
+
+    /** Number of VPN attempts before waiting for user intervention. */
+    private static final int MAX_ERROR_COUNT = 4;
+
+    private static final String ACTION_LOCKDOWN_RESET = "com.android.server.action.LOCKDOWN_RESET";
+
+    private final Context mContext;
+    private final INetworkManagementService mNetService;
+    private final ConnectivityService mConnService;
+    private final Vpn mVpn;
+    private final VpnProfile mProfile;
+
+    private final Object mStateLock = new Object();
+
+    private PendingIntent mResetIntent;
+
+    private String mAcceptedEgressIface;
+    private String mAcceptedIface;
+    private String mAcceptedSourceAddr;
+
+    private int mErrorCount;
+
+    public static boolean isEnabled() {
+        return KeyStore.getInstance().contains(Credentials.LOCKDOWN_VPN);
+    }
+
+    public LockdownVpnTracker(Context context, INetworkManagementService netService,
+            ConnectivityService connService, Vpn vpn, VpnProfile profile) {
+        mContext = Preconditions.checkNotNull(context);
+        mNetService = Preconditions.checkNotNull(netService);
+        mConnService = Preconditions.checkNotNull(connService);
+        mVpn = Preconditions.checkNotNull(vpn);
+        mProfile = Preconditions.checkNotNull(profile);
+
+        final Intent intent = new Intent(ACTION_LOCKDOWN_RESET);
+        intent.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY);
+        mResetIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0);
+    }
+
+    private BroadcastReceiver mResetReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            reset();
+        }
+    };
+
+    /**
+     * Watch for state changes to both active egress network, kicking off a VPN
+     * connection when ready, or setting firewall rules once VPN is connected.
+     */
+    private void handleStateChangedLocked() {
+        Slog.d(TAG, "handleStateChanged()");
+
+        final NetworkInfo egressInfo = mConnService.getActiveNetworkInfoUnfiltered();
+        final LinkProperties egressProp = mConnService.getActiveLinkProperties();
+
+        final NetworkInfo vpnInfo = mVpn.getNetworkInfo();
+        final VpnConfig vpnConfig = mVpn.getLegacyVpnConfig();
+
+        // Restart VPN when egress network disconnected or changed
+        final boolean egressDisconnected = egressInfo == null
+                || State.DISCONNECTED.equals(egressInfo.getState());
+        final boolean egressChanged = egressProp == null
+                || !TextUtils.equals(mAcceptedEgressIface, egressProp.getInterfaceName());
+        if (egressDisconnected || egressChanged) {
+            clearSourceRules();
+            mAcceptedEgressIface = null;
+            mVpn.stopLegacyVpn();
+        }
+        if (egressDisconnected) return;
+
+        if (mErrorCount > MAX_ERROR_COUNT) {
+            showNotification(R.string.vpn_lockdown_error, R.drawable.vpn_disconnected);
+
+        } else if (egressInfo.isConnected() && !vpnInfo.isConnectedOrConnecting()) {
+            if (mProfile.isValidLockdownProfile()) {
+                Slog.d(TAG, "Active network connected; starting VPN");
+                showNotification(R.string.vpn_lockdown_connecting, R.drawable.vpn_disconnected);
+
+                mAcceptedEgressIface = egressProp.getInterfaceName();
+                mVpn.startLegacyVpn(mProfile, KeyStore.getInstance(), egressProp);
+
+            } else {
+                Slog.e(TAG, "Invalid VPN profile; requires IP-based server and DNS");
+                showNotification(R.string.vpn_lockdown_error, R.drawable.vpn_disconnected);
+            }
+
+        } else if (vpnInfo.isConnected() && vpnConfig != null) {
+            final String iface = vpnConfig.interfaze;
+            final String sourceAddr = vpnConfig.addresses;
+
+            if (TextUtils.equals(iface, mAcceptedIface)
+                    && TextUtils.equals(sourceAddr, mAcceptedSourceAddr)) {
+                return;
+            }
+
+            Slog.d(TAG, "VPN connected using iface=" + iface + ", sourceAddr=" + sourceAddr);
+            showNotification(R.string.vpn_lockdown_connected, R.drawable.vpn_connected);
+
+            try {
+                clearSourceRules();
+
+                mNetService.setFirewallInterfaceRule(iface, true);
+                mNetService.setFirewallEgressSourceRule(sourceAddr, true);
+
+                mErrorCount = 0;
+                mAcceptedIface = iface;
+                mAcceptedSourceAddr = sourceAddr;
+            } catch (RemoteException e) {
+                throw new RuntimeException("Problem setting firewall rules", e);
+            }
+
+            mConnService.sendConnectedBroadcast(augmentNetworkInfo(egressInfo));
+        }
+    }
+
+    public void init() {
+        Slog.d(TAG, "init()");
+
+        mVpn.setEnableNotifications(false);
+
+        final IntentFilter resetFilter = new IntentFilter(ACTION_LOCKDOWN_RESET);
+        mContext.registerReceiver(mResetReceiver, resetFilter, CONNECTIVITY_INTERNAL, null);
+
+        try {
+            // TODO: support non-standard port numbers
+            mNetService.setFirewallEgressDestRule(mProfile.server, 500, true);
+            mNetService.setFirewallEgressDestRule(mProfile.server, 4500, true);
+        } catch (RemoteException e) {
+            throw new RuntimeException("Problem setting firewall rules", e);
+        }
+
+        synchronized (mStateLock) {
+            handleStateChangedLocked();
+        }
+    }
+
+    public void shutdown() {
+        Slog.d(TAG, "shutdown()");
+
+        mAcceptedEgressIface = null;
+        mErrorCount = 0;
+
+        mVpn.stopLegacyVpn();
+        try {
+            mNetService.setFirewallEgressDestRule(mProfile.server, 500, false);
+            mNetService.setFirewallEgressDestRule(mProfile.server, 4500, false);
+        } catch (RemoteException e) {
+            throw new RuntimeException("Problem setting firewall rules", e);
+        }
+        clearSourceRules();
+        hideNotification();
+
+        mContext.unregisterReceiver(mResetReceiver);
+        mVpn.setEnableNotifications(true);
+    }
+
+    public void reset() {
+        // cycle tracker, reset error count, and trigger retry
+        shutdown();
+        init();
+        synchronized (mStateLock) {
+            handleStateChangedLocked();
+        }
+    }
+
+    private void clearSourceRules() {
+        try {
+            if (mAcceptedIface != null) {
+                mNetService.setFirewallInterfaceRule(mAcceptedIface, false);
+                mAcceptedIface = null;
+            }
+            if (mAcceptedSourceAddr != null) {
+                mNetService.setFirewallEgressSourceRule(mAcceptedSourceAddr, false);
+                mAcceptedSourceAddr = null;
+            }
+        } catch (RemoteException e) {
+            throw new RuntimeException("Problem setting firewall rules", e);
+        }
+    }
+
+    public void onNetworkInfoChanged(NetworkInfo info) {
+        synchronized (mStateLock) {
+            handleStateChangedLocked();
+        }
+    }
+
+    public void onVpnStateChanged(NetworkInfo info) {
+        if (info.getDetailedState() == DetailedState.FAILED) {
+            mErrorCount++;
+        }
+        synchronized (mStateLock) {
+            handleStateChangedLocked();
+        }
+    }
+
+    public NetworkInfo augmentNetworkInfo(NetworkInfo info) {
+        final NetworkInfo vpnInfo = mVpn.getNetworkInfo();
+        info = new NetworkInfo(info);
+        info.setDetailedState(vpnInfo.getDetailedState(), vpnInfo.getReason(), null);
+        return info;
+    }
+
+    private void showNotification(int titleRes, int iconRes) {
+        final Notification.Builder builder = new Notification.Builder(mContext);
+        builder.setWhen(0);
+        builder.setSmallIcon(iconRes);
+        builder.setContentTitle(mContext.getString(titleRes));
+        builder.setContentText(mContext.getString(R.string.vpn_lockdown_reset));
+        builder.setContentIntent(mResetIntent);
+        builder.setPriority(Notification.PRIORITY_LOW);
+        builder.setOngoing(true);
+        NotificationManager.from(mContext).notify(TAG, 0, builder.build());
+    }
+
+    private void hideNotification() {
+        NotificationManager.from(mContext).cancel(TAG, 0);
+    }
+}