Merge "Enable fastbootd on cuttlefish"
diff --git a/TEST_MAPPING b/TEST_MAPPING
index 8c7bc4b..5666f32 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -2,6 +2,9 @@
   "postsubmit" : [
     {
       "name": "tombstone_transmit_tests"
+    },
+    {
+      "name": "RebootRecoveryTest"
     }
   ],
   "presubmit": [
diff --git a/build/Android.bp b/build/Android.bp
index e882b70..c99ce20 100644
--- a/build/Android.bp
+++ b/build/Android.bp
@@ -12,6 +12,18 @@
     pluginFor: ["soong_build"],
 }
 
+// Allow cvd-host-package.go to read custom action config variables
+// from ctx.Config().VendorConfig("cvd")
+soong_config_module_type {
+    name: "cvd_host_package_customization",
+    module_type: "cvd_host_package",
+    config_namespace: "cvd",
+    value_variables: [
+        "custom_action_config",
+        "custom_action_servers",
+    ],
+}
+
 cvd_host_tools = [
     "android.hardware.automotive.vehicle@2.0-virtualization-grpc-server",
     "adb",
@@ -130,7 +142,7 @@
     "x86_64_linux_gnu_libOpenglRender.so_for_crosvm",
 ]
 
-cvd_host_package {
+cvd_host_package_customization {
     name: "cvd-host_package",
     deps: cvd_host_tools +
         cvd_host_tests,
diff --git a/build/README.md b/build/README.md
new file mode 100644
index 0000000..dc66c0a
--- /dev/null
+++ b/build/README.md
@@ -0,0 +1,27 @@
+## Custom Actions
+
+To add custom actions to the WebRTC control panel, create a custom action config
+JSON file in your virtual device product makefile directory, create a
+`prebuilt_etc_host` module for the JSON file with `sub_dir`
+`cvd_custom_action_config`, then set the build variable
+`SOONG_CONFIG_cvd_custom_action_config` to the name of that module. For example:
+
+```
+Android.bp:
+  prebuilt_etc_host {
+      name: "my_custom_action_config.json",
+      src: "my_custom_action_config.json",
+      // The sub_dir must always equal the following value:
+      sub_dir: "cvd_custom_action_config",
+  }
+
+my_virtual_device.mk:
+  SOONG_CONFIG_NAMESPACES += cvd
+  SOONG_CONFIG_cvd += custom_action_config
+  SOONG_CONFIG_cvd_custom_action_config := my_custom_action_config.json
+```
+
+TODO(b/171709037): Add documentation to source.android.com
+
+See https://source.android.com/setup/create/cuttlefish-control-panel for
+detailed information about the format of the config file.
diff --git a/build/cvd-host-package.go b/build/cvd-host-package.go
index 67e218d..53202c2 100644
--- a/build/cvd-host-package.go
+++ b/build/cvd-host-package.go
@@ -16,6 +16,7 @@
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/google/blueprint"
 
@@ -56,6 +57,7 @@
 			ctx.AddVariationDependencies(nil, cvdHostPackageDependencyTag, dep)
 		}
 	}
+
 	variations := []blueprint.Variation{
 		{Mutator: "os", Variation: ctx.Target().Os.String()},
 		{Mutator: "arch", Variation: android.Common.String()},
@@ -65,6 +67,20 @@
 			ctx.AddFarVariationDependencies(variations, cvdHostPackageDependencyTag, dep)
 		}
 	}
+
+	// If cvd_custom_action_config is set, include custom action servers in the
+	// host package as specified by cvd_custom_action_servers.
+	customActionConfig := ctx.Config().VendorConfig("cvd").String("custom_action_config")
+	if customActionConfig != "" && ctx.OtherModuleExists(customActionConfig) {
+		ctx.AddVariationDependencies(variations, cvdHostPackageDependencyTag,
+			customActionConfig)
+		for _, dep := range strings.Split(
+			ctx.Config().VendorConfig("cvd").String("custom_action_servers"), " ") {
+			if ctx.OtherModuleExists(dep) {
+				ctx.AddVariationDependencies(nil, cvdHostPackageDependencyTag, dep)
+			}
+		}
+	}
 }
 
 var pctx = android.NewPackageContext("android/soong/cuttlefish")
diff --git a/common/libs/utils/subprocess.h b/common/libs/utils/subprocess.h
index d9a571e..413444f 100644
--- a/common/libs/utils/subprocess.h
+++ b/common/libs/utils/subprocess.h
@@ -23,6 +23,8 @@
 #include <string>
 #include <vector>
 
+#include <android-base/logging.h>
+
 #include <common/libs/fs/shared_fd.h>
 
 namespace cuttlefish {
@@ -183,6 +185,21 @@
     }
     return false;
   }
+  // Similar to AddParameter, except the args are appended to the last (most
+  // recently-added) parameter in the command.
+  template <typename... Args>
+  bool AppendToLastParameter(Args... args) {
+    if (command_.empty()) {
+      LOG(ERROR) << "There is no parameter to append to.";
+      return false;
+    }
+    std::stringstream ss;
+    if (BuildParameter(&ss, args...)) {
+      command_[command_.size()-1] += ss.str();
+      return true;
+    }
+    return false;
+  }
 
   ParameterBuilder GetParameterBuilder() { return ParameterBuilder(this); }
 
diff --git a/guest/hals/hwcomposer/vsocket_screen_view.cpp b/guest/hals/hwcomposer/vsocket_screen_view.cpp
index 7fbe0ba..0960688 100644
--- a/guest/hals/hwcomposer/vsocket_screen_view.cpp
+++ b/guest/hals/hwcomposer/vsocket_screen_view.cpp
@@ -91,13 +91,19 @@
         "Compositions will occur, but frames won't be sent anywhere");
     return;
   }
-  int current_seq = 0;
+  // The client detector thread needs to be started after the connection to the
+  // socket has been made
+  client_detector_thread_ = std::thread([this]() { ClientDetectorLoop(); });
+
+  unsigned int current_seq = 0;
+  unsigned int last_sent_seq = 0;
   int current_offset;
   ALOGI("Broadcaster thread loop starting");
   while (true) {
     {
       std::unique_lock<std::mutex> lock(mutex_);
-      while (running_ && current_seq == current_seq_) {
+      while (running_ && current_seq == current_seq_ &&
+             (!send_frames_ || last_sent_seq == current_seq)) {
         cond_var_.wait(lock);
       }
       if (!running_) {
@@ -107,17 +113,50 @@
       current_offset = current_offset_;
       current_seq = current_seq_;
     }
-    int32_t size = buffer_size();
-    screen_server_->Write(&size, sizeof(size));
-    auto buff = static_cast<char*>(GetBuffer(current_offset));
-    while (size > 0) {
-      auto written = screen_server_->Write(buff, size);
-      if (written == -1) {
-        ALOGE("Broadcaster thread failed to write frame: %s", strerror(errno));
+    if (send_frames_ && last_sent_seq != current_seq) {
+      last_sent_seq = current_seq;
+      if (!SendFrame(current_offset)) {
         break;
       }
-      size -= written;
-      buff += written;
+    }
+  }
+}
+
+bool VsocketScreenView::SendFrame(int offset) {
+  int32_t size = buffer_size();
+  screen_server_->Write(&size, sizeof(size));
+  auto buff = static_cast<char*>(GetBuffer(offset));
+  while (size > 0) {
+    auto written = screen_server_->Write(buff, size);
+    if (written == -1) {
+      ALOGE("Broadcaster thread failed to write frame: %s",
+            screen_server_->StrError());
+      return false;
+    }
+    size -= written;
+    buff += written;
+  }
+  return true;
+}
+
+void VsocketScreenView::ClientDetectorLoop() {
+  char buffer[8];
+  while (running_) {
+    auto read = screen_server_->Read(buffer, sizeof(buffer));
+    if (read == -1) {
+      ALOGE("Client detector thread failed to read from screen server: %s",
+            screen_server_->StrError());
+      break;
+    }
+    {
+      std::lock_guard<std::mutex> lock(mutex_);
+      // The last byte sent by the server indicates the presence of clients.
+      send_frames_ = read > 0 && buffer[read - 1];
+      cond_var_.notify_all();
+    }
+    if (read == 0) {
+      ALOGE("screen server closed!");
+      break;
     }
   }
 }
diff --git a/guest/hals/hwcomposer/vsocket_screen_view.h b/guest/hals/hwcomposer/vsocket_screen_view.h
index edd41c9..cb4f784 100644
--- a/guest/hals/hwcomposer/vsocket_screen_view.h
+++ b/guest/hals/hwcomposer/vsocket_screen_view.h
@@ -46,12 +46,15 @@
   bool ConnectToScreenServer();
   void GetScreenParameters();
   void BroadcastLoop();
+  void ClientDetectorLoop();
+  bool SendFrame(int offset);
 
   std::vector<char> inner_buffer_;
   cuttlefish::SharedFD screen_server_;
   std::thread broadcast_thread_;
+  std::thread client_detector_thread_;
   int current_offset_ = 0;
-  int current_seq_ = 0;
+  unsigned int current_seq_ = 0;
   std::mutex mutex_;
   std::condition_variable cond_var_;
   bool running_ = true;
@@ -59,6 +62,7 @@
   int32_t y_res_{1280};
   int32_t dpi_{160};
   int32_t refresh_rate_{60};
+  bool send_frames_{false};
 };
 
 }  // namespace cuttlefish
diff --git a/guest/monitoring/cuttlefish_service/java/com/android/google/gce/gceservice/ConnectivityChecker.java b/guest/monitoring/cuttlefish_service/java/com/android/google/gce/gceservice/ConnectivityChecker.java
index 84998ee..43f4e3b 100644
--- a/guest/monitoring/cuttlefish_service/java/com/android/google/gce/gceservice/ConnectivityChecker.java
+++ b/guest/monitoring/cuttlefish_service/java/com/android/google/gce/gceservice/ConnectivityChecker.java
@@ -28,12 +28,15 @@
     private static final String LOG_TAG = "GceConnChecker";
     private static final String MOBILE_NETWORK_CONNECTED_MESSAGE =
         "VIRTUAL_DEVICE_NETWORK_MOBILE_CONNECTED";
+    private static final String ETHERNET_NETWORK_CONNECTED_MESSAGE =
+        "VIRTUAL_DEVICE_NETWORK_ETHERNET_CONNECTED";
 
     private final Context mContext;
     private final EventReporter mEventReporter;
     private final GceFuture<Boolean> mConnected = new GceFuture<Boolean>("Connectivity");
     // TODO(schuffelen): Figure out why this has to be static in order to not report 3 times.
     private static boolean reportedMobileConnectivity = false;
+    private static boolean reportedEthernetConnectivity = false;
 
     public ConnectivityChecker(Context context, EventReporter eventReporter) {
         super(LOG_TAG);
@@ -54,11 +57,16 @@
             NetworkInfo info = connManager.getNetworkInfo(network);
             if (info.isConnected()) {
                 NetworkCapabilities capabilities = connManager.getNetworkCapabilities(network);
-                if (capabilities != null
-                        && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
-                        && !reportedMobileConnectivity) {
-                    mEventReporter.reportMessage(MOBILE_NETWORK_CONNECTED_MESSAGE);
-                    reportedMobileConnectivity = true;
+                if (capabilities != null) {
+                    if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
+                            && !reportedMobileConnectivity) {
+                        mEventReporter.reportMessage(MOBILE_NETWORK_CONNECTED_MESSAGE);
+                        reportedMobileConnectivity = true;
+                    } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
+                                   && !reportedEthernetConnectivity) {
+                        mEventReporter.reportMessage(ETHERNET_NETWORK_CONNECTED_MESSAGE);
+                        reportedEthernetConnectivity = true;
+                    }
                 }
             }
         }
diff --git a/host/commands/assemble_cvd/Android.bp b/host/commands/assemble_cvd/Android.bp
index 0f78b2a..2bbbf06 100644
--- a/host/commands/assemble_cvd/Android.bp
+++ b/host/commands/assemble_cvd/Android.bp
@@ -31,10 +31,12 @@
 cc_binary {
     name: "assemble_cvd",
     srcs: [
+        "alloc.cc",
         "assemble_cvd.cc",
         "boot_config.cc",
         "boot_image_unpacker.cc",
         "boot_image_utils.cc",
+        "clean.cc",
         "disk_flags.cc",
         "flags.cc",
         "image_aggregator.cc",
diff --git a/host/commands/assemble_cvd/alloc.cc b/host/commands/assemble_cvd/alloc.cc
new file mode 100644
index 0000000..493de55
--- /dev/null
+++ b/host/commands/assemble_cvd/alloc.cc
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+
+#include "host/commands/assemble_cvd/alloc.h"
+
+#include <iomanip>
+#include <sstream>
+
+#include "common/libs/fs/shared_fd.h"
+#include "host/commands/assemble_cvd/assembler_defs.h"
+#include "host/libs/allocd/request.h"
+#include "host/libs/allocd/utils.h"
+
+static std::string StrForInstance(const std::string& prefix, int num) {
+  std::ostringstream stream;
+  stream << prefix << std::setfill('0') << std::setw(2) << num;
+  return stream.str();
+}
+
+IfaceConfig DefaultNetworkInterfaces(int num) {
+  IfaceConfig config{};
+  config.mobile_tap.name = StrForInstance("cvd-mtap-", num);
+  config.mobile_tap.resource_id = 0;
+  config.mobile_tap.session_id = 0;
+
+  config.wireless_tap.name = StrForInstance("cvd-wtap-", num);
+  config.wireless_tap.resource_id = 0;
+  config.wireless_tap.session_id = 0;
+
+  config.ethernet_tap.name = StrForInstance("cvd-etap-", num);
+  config.ethernet_tap.resource_id = 0;
+  config.ethernet_tap.session_id = 0;
+
+  return config;
+}
+
+std::optional<IfaceConfig> AllocateNetworkInterfaces() {
+  IfaceConfig config{};
+
+  cuttlefish::SharedFD allocd_sock = cuttlefish::SharedFD::SocketLocalClient(
+      cuttlefish::kDefaultLocation, false, SOCK_STREAM);
+  if (!allocd_sock->IsOpen()) {
+    LOG(FATAL) << "Unable to connect to allocd on "
+               << cuttlefish::kDefaultLocation << ": "
+               << allocd_sock->StrError();
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+
+  Json::Value resource_config;
+  Json::Value request_list;
+  Json::Value req;
+  req["request_type"] = "create_interface";
+  req["uid"] = geteuid();
+  req["iface_type"] = "mtap";
+  request_list.append(req);
+  req["iface_type"] = "wtap";
+  request_list.append(req);
+  req["iface_type"] = "etap";
+  request_list.append(req);
+
+  resource_config["config_request"]["request_list"] = request_list;
+
+  if (!cuttlefish::SendJsonMsg(allocd_sock, resource_config)) {
+    LOG(FATAL) << "Failed to send JSON to allocd\n";
+    return std::nullopt;
+  }
+
+  auto resp_opt = cuttlefish::RecvJsonMsg(allocd_sock);
+  if (!resp_opt.has_value()) {
+    LOG(FATAL) << "Bad Response from allocd\n";
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+  auto resp = resp_opt.value();
+
+  if (!resp.isMember("config_status") || !resp["config_status"].isString()) {
+    LOG(FATAL) << "Bad response from allocd: " << resp;
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+
+  if (resp["config_status"].asString() !=
+      cuttlefish::StatusToStr(cuttlefish::RequestStatus::Success)) {
+    LOG(FATAL) << "Failed to allocate interfaces " << resp;
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+
+  if (!resp.isMember("session_id") || !resp["session_id"].isUInt()) {
+    LOG(FATAL) << "Bad response from allocd: " << resp;
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+  auto session_id = resp["session_id"].asUInt();
+
+  if (!resp.isMember("response_list") || !resp["response_list"].isArray()) {
+    LOG(FATAL) << "Bad response from allocd: " << resp;
+    exit(cuttlefish::kAllocdConnectionError);
+  }
+
+  Json::Value resp_list = resp["response_list"];
+  Json::Value mtap_resp;
+  Json::Value wtap_resp;
+  Json::Value etap_resp;
+  for (Json::Value::ArrayIndex i = 0; i != resp_list.size(); ++i) {
+    auto ty = cuttlefish::StrToIfaceTy(resp_list[i]["iface_type"].asString());
+
+    switch (ty) {
+      case cuttlefish::IfaceType::mtap: {
+        mtap_resp = resp_list[i];
+        break;
+      }
+      case cuttlefish::IfaceType::wtap: {
+        wtap_resp = resp_list[i];
+        break;
+      }
+      case cuttlefish::IfaceType::etap: {
+        etap_resp = resp_list[i];
+        break;
+      }
+      default: {
+        break;
+      }
+    }
+  }
+
+  if (!mtap_resp.isMember("iface_type")) {
+    LOG(ERROR) << "Missing mtap response from allocd";
+    return std::nullopt;
+  }
+  if (!wtap_resp.isMember("iface_type")) {
+    LOG(ERROR) << "Missing wtap response from allocd";
+    return std::nullopt;
+  }
+  if (!etap_resp.isMember("iface_type")) {
+    LOG(ERROR) << "Missing etap response from allocd";
+    return std::nullopt;
+  }
+
+  config.mobile_tap.name = mtap_resp["iface_name"].asString();
+  config.mobile_tap.resource_id = mtap_resp["resource_id"].asUInt();
+  config.mobile_tap.session_id = session_id;
+
+  config.wireless_tap.name = wtap_resp["iface_name"].asString();
+  config.wireless_tap.resource_id = wtap_resp["resource_id"].asUInt();
+  config.wireless_tap.session_id = session_id;
+
+  config.ethernet_tap.name = etap_resp["iface_name"].asString();
+  config.ethernet_tap.resource_id = etap_resp["resource_id"].asUInt();
+  config.ethernet_tap.session_id = session_id;
+
+  return config;
+}
+
diff --git a/host/commands/assemble_cvd/alloc.h b/host/commands/assemble_cvd/alloc.h
new file mode 100644
index 0000000..0de8795
--- /dev/null
+++ b/host/commands/assemble_cvd/alloc.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+#pragma once
+
+#include <stdint.h>
+#include <optional>
+#include <string>
+
+struct IfaceData {
+  std::string name;
+  uint32_t session_id;
+  uint32_t resource_id;
+};
+
+struct IfaceConfig {
+  IfaceData mobile_tap;
+  IfaceData wireless_tap;
+  IfaceData ethernet_tap;
+};
+
+IfaceConfig DefaultNetworkInterfaces(int num);
+
+// Acquires interfaces from the resource allocator daemon.
+std::optional<IfaceConfig> AllocateNetworkInterfaces();
diff --git a/host/commands/assemble_cvd/clean.cc b/host/commands/assemble_cvd/clean.cc
new file mode 100644
index 0000000..add0e87
--- /dev/null
+++ b/host/commands/assemble_cvd/clean.cc
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+#include "host/commands/assemble_cvd/clean.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <sys/stat.h>
+
+#include <regex>
+#include <vector>
+
+#include <android-base/logging.h>
+
+#include "host/commands/assemble_cvd/flags.h"
+#include "common/libs/utils/files.h"
+
+namespace {
+
+bool CleanPriorFiles(const std::string& path, const std::set<std::string>& preserving) {
+  if (preserving.count(cuttlefish::cpp_basename(path))) {
+    LOG(DEBUG) << "Preserving: " << path;
+    return true;
+  }
+  struct stat statbuf;
+  if (lstat(path.c_str(), &statbuf) < 0) {
+    int error_num = errno;
+    if (error_num == ENOENT) {
+      return true;
+    } else {
+      LOG(ERROR) << "Could not stat \"" << path << "\": " << strerror(error_num);
+      return false;
+    }
+  }
+  if ((statbuf.st_mode & S_IFMT) != S_IFDIR) {
+    LOG(DEBUG) << "Deleting: " << path;
+    if (unlink(path.c_str()) < 0) {
+      int error_num = errno;
+      LOG(ERROR) << "Could not unlink \"" << path << "\", error was " << strerror(error_num);
+      return false;
+    }
+    return true;
+  }
+  std::unique_ptr<DIR, int(*)(DIR*)> dir(opendir(path.c_str()), closedir);
+  if (!dir) {
+    int error_num = errno;
+    LOG(ERROR) << "Could not clean \"" << path << "\": error was " << strerror(error_num);
+    return false;
+  }
+  for (auto entity = readdir(dir.get()); entity != nullptr; entity = readdir(dir.get())) {
+    std::string entity_name(entity->d_name);
+    if (entity_name == "." || entity_name == "..") {
+      continue;
+    }
+    std::string entity_path = path + "/" + entity_name;
+    if (!CleanPriorFiles(entity_path.c_str(), preserving)) {
+      return false;
+    }
+  }
+  if (rmdir(path.c_str()) < 0) {
+    if (!(errno == EEXIST || errno == ENOTEMPTY)) {
+      // If EEXIST or ENOTEMPTY, probably because a file was preserved
+      int error_num = errno;
+      LOG(ERROR) << "Could not rmdir \"" << path << "\", error was " << strerror(error_num);
+      return false;
+    }
+  }
+  return true;
+}
+
+bool CleanPriorFiles(const std::vector<std::string>& paths, const std::set<std::string>& preserving) {
+  std::string prior_files;
+  for (auto path : paths) {
+    struct stat statbuf;
+    if (stat(path.c_str(), &statbuf) < 0 && errno != ENOENT) {
+      // If ENOENT, it doesn't exist yet, so there is no work to do'
+      int error_num = errno;
+      LOG(ERROR) << "Could not stat \"" << path << "\": " << strerror(error_num);
+      return false;
+    }
+    bool is_directory = (statbuf.st_mode & S_IFMT) == S_IFDIR;
+    prior_files += (is_directory ? (path + "/*") : path) + " ";
+  }
+  LOG(DEBUG) << "Assuming prior files of " << prior_files;
+  std::string lsof_cmd = "lsof -t " + prior_files + " >/dev/null 2>&1";
+  int rval = std::system(lsof_cmd.c_str());
+  // lsof returns 0 if any of the files are open
+  if (WEXITSTATUS(rval) == 0) {
+    LOG(ERROR) << "Clean aborted: files are in use";
+    return false;
+  }
+  for (const auto& path : paths) {
+    if (!CleanPriorFiles(path, preserving)) {
+      LOG(ERROR) << "Remove of file under \"" << path << "\" failed";
+      return false;
+    }
+  }
+  return true;
+}
+
+} // namespace
+
+bool CleanPriorFiles(
+    const std::set<std::string>& preserving,
+    const std::string& assembly_dir,
+    const std::string& instance_dir) {
+  std::vector<std::string> paths = {
+    // Everything in the assembly directory
+    assembly_dir,
+    // The environment file
+    GetCuttlefishEnvPath(),
+    // The global link to the config file
+    cuttlefish::GetGlobalConfigFileLink(),
+  };
+
+  std::string runtime_dir_parent =
+      cuttlefish::cpp_dirname(cuttlefish::AbsolutePath(instance_dir));
+  std::string runtime_dirs_basename =
+      cuttlefish::cpp_basename(cuttlefish::AbsolutePath(instance_dir));
+
+  std::regex instance_dir_regex("^.+\\.[1-9]\\d*$");
+  for (const auto& path : cuttlefish::DirectoryContents(runtime_dir_parent)) {
+    std::string absl_path = runtime_dir_parent + "/" + path;
+    if((path.rfind(runtime_dirs_basename, 0) == 0) && std::regex_match(path, instance_dir_regex) &&
+        cuttlefish::DirectoryExists(absl_path)) {
+      paths.push_back(absl_path);
+    }
+  }
+  paths.push_back(instance_dir);
+  return CleanPriorFiles(paths, preserving);
+}
diff --git a/host/commands/assemble_cvd/clean.h b/host/commands/assemble_cvd/clean.h
new file mode 100644
index 0000000..e24080a
--- /dev/null
+++ b/host/commands/assemble_cvd/clean.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+#pragma once
+
+#include <set>
+#include <string>
+
+bool CleanPriorFiles(
+    const std::set<std::string>& preserving,
+    const std::string& assembly_dir,
+    const std::string& instance_dir);
diff --git a/host/commands/assemble_cvd/flags.cc b/host/commands/assemble_cvd/flags.cc
index 7571817..511b6f0 100644
--- a/host/commands/assemble_cvd/flags.cc
+++ b/host/commands/assemble_cvd/flags.cc
@@ -23,13 +23,13 @@
 #include "common/libs/utils/environment.h"
 #include "common/libs/utils/files.h"
 #include "common/libs/utils/tee_logging.h"
+#include "host/commands/assemble_cvd/alloc.h"
 #include "host/commands/assemble_cvd/assembler_defs.h"
 #include "host/commands/assemble_cvd/boot_config.h"
 #include "host/commands/assemble_cvd/boot_image_unpacker.h"
+#include "host/commands/assemble_cvd/clean.h"
 #include "host/commands/assemble_cvd/disk_flags.h"
 #include "host/commands/assemble_cvd/image_aggregator.h"
-#include "host/libs/allocd/request.h"
-#include "host/libs/allocd/utils.h"
 #include "host/libs/config/data_image.h"
 #include "host/libs/config/fetcher_config.h"
 #include "host/libs/config/host_tools_version.h"
@@ -238,6 +238,10 @@
 DEFINE_bool(restart_subprocesses, true, "Restart any crashed host process");
 DEFINE_bool(enable_vehicle_hal_grpc_server, true, "Enables the vehicle HAL "
             "emulation gRPC server on the host");
+DEFINE_string(custom_action_config, "",
+              "Path to a custom action config JSON. Defaults to the file provided by "
+              "build variable CVD_CUSTOM_ACTION_CONFIG. If this build variable "
+              "is empty then the custom action config will be empty as well.");
 DEFINE_bool(use_bootloader, true, "Boots the device using a bootloader");
 DEFINE_string(bootloader, "", "Bootloader binary path");
 DEFINE_string(boot_slot, "", "Force booting into the given slot. If empty, "
@@ -280,6 +284,8 @@
              "the vsock cid of the i th instance would be C + i where i is in [1, N]"
              "If --num_instances is not given, the default value of N is used.");
 
+DEFINE_bool(ethernet, false, "Enable Ethernet network interface");
+
 DECLARE_string(system_image_dir);
 
 namespace {
@@ -301,10 +307,6 @@
   return port_range;
 }
 
-std::string GetCuttlefishEnvPath() {
-  return cuttlefish::StringFromEnv("HOME", ".") + "/.cuttlefish.sh";
-}
-
 std::string GetLegacyConfigFilePath(const cuttlefish::CuttlefishConfig& config) {
   return config.ForDefaultInstance().PerInstancePath("cuttlefish_config.json");
 }
@@ -545,6 +547,44 @@
   tmp_config_obj.set_vehicle_hal_grpc_server_binary(
       cuttlefish::DefaultHostArtifactsPath("bin/android.hardware.automotive.vehicle@2.0-virtualization-grpc-server"));
 
+  std::string custom_action_config;
+  if (!FLAGS_custom_action_config.empty()) {
+    custom_action_config = FLAGS_custom_action_config;
+  } else {
+    std::string custom_action_config_dir =
+        cuttlefish::DefaultHostArtifactsPath("etc/cvd_custom_action_config");
+    if (cuttlefish::DirectoryExists(custom_action_config_dir)) {
+      auto custom_action_configs = cuttlefish::DirectoryContents(
+          custom_action_config_dir);
+      // Two entries are always . and ..
+      if (custom_action_configs.size() > 3) {
+        LOG(ERROR) << "Expected at most one custom action config in "
+                   << custom_action_config_dir << ". Please delete extras.";
+      } else if (custom_action_configs.size() == 3) {
+        for (const auto& config : custom_action_configs) {
+          if (android::base::EndsWithIgnoreCase(config, ".json")) {
+            custom_action_config = custom_action_config_dir + "/" + config;
+          }
+        }
+      }
+    }
+  }
+  // Load the custom action config JSON.
+  if (custom_action_config != "") {
+    Json::Reader reader;
+    std::ifstream ifs(custom_action_config);
+    Json::Value dictionary;
+    if (!reader.parse(ifs, dictionary)) {
+      LOG(ERROR) << "Could not read custom actions config file " << custom_action_config
+                 << ": " << reader.getFormattedErrorMessages();
+    }
+    std::vector<cuttlefish::CustomActionConfig> custom_actions;
+    for (Json::Value custom_action : dictionary) {
+      custom_actions.push_back(cuttlefish::CustomActionConfig(custom_action));
+    }
+    tmp_config_obj.set_custom_actions(custom_actions);
+  }
+
   tmp_config_obj.set_use_bootloader(FLAGS_use_bootloader);
   tmp_config_obj.set_bootloader(FLAGS_bootloader);
 
@@ -562,6 +602,8 @@
 
   tmp_config_obj.set_vhost_net(FLAGS_vhost_net);
 
+  tmp_config_obj.set_ethernet(FLAGS_ethernet);
+
   std::vector<int> num_instances;
   for (int i = 0; i < FLAGS_num_instances; i++) {
     num_instances.push_back(cuttlefish::GetInstance() + i);
@@ -569,12 +611,17 @@
 
   bool is_first_instance = true;
   for (const auto& num : num_instances) {
-    auto iface_opt = AcquireIfaces(num);
-    if (!iface_opt.has_value()) {
-      LOG(FATAL) << "Failed to acquire network interfaces";
+    IfaceConfig iface_config;
+    if (FLAGS_use_allocd) {
+      auto iface_opt = AllocateNetworkInterfaces();
+      if (!iface_opt.has_value()) {
+        LOG(FATAL) << "Failed to acquire network interfaces";
+      }
+      iface_config = iface_opt.value();
+    } else {
+      iface_config = DefaultNetworkInterfaces(num);
     }
 
-    auto iface_config = iface_opt.value();
     auto instance = tmp_config_obj.ForInstance(num);
     auto const_instance =
         const_cast<const cuttlefish::CuttlefishConfig&>(tmp_config_obj)
@@ -593,8 +640,8 @@
 
     instance.set_mobile_bridge_name(StrForInstance("cvd-mbr-", num));
     instance.set_mobile_tap_name(iface_config.mobile_tap.name);
-
     instance.set_wifi_tap_name(iface_config.wireless_tap.name);
+    instance.set_ethernet_tap_name(iface_config.ethernet_tap.name);
 
     instance.set_vsock_guest_cid(FLAGS_vsock_guest_cid + num - cuttlefish::GetInstance());
 
@@ -711,6 +758,25 @@
                                google::FlagSettingMode::SET_FLAGS_DEFAULT);
 }
 
+bool EnsureDirectoryExists(const std::string& directory_path) {
+  if (!cuttlefish::DirectoryExists(directory_path)) {
+    LOG(DEBUG) << "Setting up " << directory_path;
+    if (mkdir(directory_path.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
+        && errno != EEXIST) {
+      PLOG(ERROR) << "Failed to create dir: \"" << directory_path << "\" ";
+      return false;
+    }
+  }
+  return true;
+}
+
+void EnsureDirectoryExistsOrExit(
+    const std::string& directory_path, AssemblerExitCodes exit_code) {
+  if (!EnsureDirectoryExists(directory_path)) {
+    exit((int) exit_code);
+  }
+}
+
 void SetDefaultFlagsForCrosvm() {
   if (NumStreamers() == 0) {
     // This makes WebRTC the default streamer unless the user requests
@@ -724,16 +790,15 @@
   bool default_enable_sandbox = false;
   std::set<const std::string> supported_archs{std::string("x86_64")};
   if (supported_archs.find(cuttlefish::HostArch()) != supported_archs.end()) {
-    default_enable_sandbox =
-        [](const std::string& var_empty) -> bool {
-          if (cuttlefish::DirectoryExists(var_empty)) {
-            return cuttlefish::IsDirectoryEmpty(var_empty);
-          }
-          if (cuttlefish::FileExists(var_empty)) {
-            return false;
-          }
-          return (::mkdir(var_empty.c_str(), 0755) == 0);
-        }(cuttlefish::kCrosvmVarEmptyDir);
+    if (cuttlefish::DirectoryExists(cuttlefish::kCrosvmVarEmptyDir)) {
+      default_enable_sandbox =
+          cuttlefish::IsDirectoryEmpty(cuttlefish::kCrosvmVarEmptyDir);
+    } else if (cuttlefish::FileExists(cuttlefish::kCrosvmVarEmptyDir)) {
+      default_enable_sandbox = false;
+    } else {
+      default_enable_sandbox =
+          EnsureDirectoryExists(cuttlefish::kCrosvmVarEmptyDir);
+    }
   }
 
   SetCommandLineOptionWithMode("enable_sandbox",
@@ -789,114 +854,6 @@
   return ResolveInstanceFiles();
 }
 
-bool CleanPriorFiles(const std::string& path, const std::set<std::string>& preserving) {
-  if (preserving.count(cuttlefish::cpp_basename(path))) {
-    LOG(DEBUG) << "Preserving: " << path;
-    return true;
-  }
-  struct stat statbuf;
-  if (lstat(path.c_str(), &statbuf) < 0) {
-    int error_num = errno;
-    if (error_num == ENOENT) {
-      return true;
-    } else {
-      LOG(ERROR) << "Could not stat \"" << path << "\": " << strerror(error_num);
-      return false;
-    }
-  }
-  if ((statbuf.st_mode & S_IFMT) != S_IFDIR) {
-    LOG(DEBUG) << "Deleting: " << path;
-    if (unlink(path.c_str()) < 0) {
-      int error_num = errno;
-      LOG(ERROR) << "Could not unlink \"" << path << "\", error was " << strerror(error_num);
-      return false;
-    }
-    return true;
-  }
-  std::unique_ptr<DIR, int(*)(DIR*)> dir(opendir(path.c_str()), closedir);
-  if (!dir) {
-    int error_num = errno;
-    LOG(ERROR) << "Could not clean \"" << path << "\": error was " << strerror(error_num);
-    return false;
-  }
-  for (auto entity = readdir(dir.get()); entity != nullptr; entity = readdir(dir.get())) {
-    std::string entity_name(entity->d_name);
-    if (entity_name == "." || entity_name == "..") {
-      continue;
-    }
-    std::string entity_path = path + "/" + entity_name;
-    if (!CleanPriorFiles(entity_path.c_str(), preserving)) {
-      return false;
-    }
-  }
-  if (rmdir(path.c_str()) < 0) {
-    if (!(errno == EEXIST || errno == ENOTEMPTY)) {
-      // If EEXIST or ENOTEMPTY, probably because a file was preserved
-      int error_num = errno;
-      LOG(ERROR) << "Could not rmdir \"" << path << "\", error was " << strerror(error_num);
-      return false;
-    }
-  }
-  return true;
-}
-
-bool CleanPriorFiles(const std::vector<std::string>& paths, const std::set<std::string>& preserving) {
-  std::string prior_files;
-  for (auto path : paths) {
-    struct stat statbuf;
-    if (stat(path.c_str(), &statbuf) < 0 && errno != ENOENT) {
-      // If ENOENT, it doesn't exist yet, so there is no work to do'
-      int error_num = errno;
-      LOG(ERROR) << "Could not stat \"" << path << "\": " << strerror(error_num);
-      return false;
-    }
-    bool is_directory = (statbuf.st_mode & S_IFMT) == S_IFDIR;
-    prior_files += (is_directory ? (path + "/*") : path) + " ";
-  }
-  LOG(DEBUG) << "Assuming prior files of " << prior_files;
-  std::string lsof_cmd = "lsof -t " + prior_files + " >/dev/null 2>&1";
-  int rval = std::system(lsof_cmd.c_str());
-  // lsof returns 0 if any of the files are open
-  if (WEXITSTATUS(rval) == 0) {
-    LOG(ERROR) << "Clean aborted: files are in use";
-    return false;
-  }
-  for (const auto& path : paths) {
-    if (!CleanPriorFiles(path, preserving)) {
-      LOG(ERROR) << "Remove of file under \"" << path << "\" failed";
-      return false;
-    }
-  }
-  return true;
-}
-
-bool CleanPriorFiles(const std::set<std::string>& preserving) {
-  std::vector<std::string> paths = {
-    // Everything in the assembly directory
-    FLAGS_assembly_dir,
-    // The environment file
-    GetCuttlefishEnvPath(),
-    // The global link to the config file
-    cuttlefish::GetGlobalConfigFileLink(),
-  };
-
-  std::string runtime_dir_parent =
-      cuttlefish::cpp_dirname(cuttlefish::AbsolutePath(FLAGS_instance_dir));
-  std::string runtime_dirs_basename =
-      cuttlefish::cpp_basename(cuttlefish::AbsolutePath(FLAGS_instance_dir));
-
-  std::regex instance_dir_regex("^.+\\.[1-9]\\d*$");
-  for (const auto& path : cuttlefish::DirectoryContents(runtime_dir_parent)) {
-    std::string absl_path = runtime_dir_parent + "/" + path;
-    if((path.rfind(runtime_dirs_basename, 0) == 0) && std::regex_match(path, instance_dir_regex) &&
-        cuttlefish::DirectoryExists(absl_path)) {
-      paths.push_back(absl_path);
-    }
-  }
-  paths.push_back(FLAGS_instance_dir);
-  return CleanPriorFiles(paths, preserving);
-}
-
 void ValidateAdbModeFlag(const cuttlefish::CuttlefishConfig& config) {
   auto adb_modes = config.adb_mode();
   adb_modes.erase(cuttlefish::AdbMode::Unknown);
@@ -976,64 +933,31 @@
         ss.str("");
       }
     }
-    if (!CleanPriorFiles(preserving)) {
+    if (!CleanPriorFiles(preserving, FLAGS_assembly_dir, FLAGS_instance_dir)) {
       LOG(ERROR) << "Failed to clean prior files";
       exit(AssemblerExitCodes::kPrioFilesCleanupError);
     }
     // Create assembly directory if it doesn't exist.
-    if (!cuttlefish::DirectoryExists(FLAGS_assembly_dir.c_str())) {
-      LOG(DEBUG) << "Setting up " << FLAGS_assembly_dir;
-      if (mkdir(FLAGS_assembly_dir.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
-          && errno != EEXIST) {
-        LOG(ERROR) << "Failed to create assembly directory: "
-                  << FLAGS_assembly_dir << ". Error: " << errno;
-        exit(AssemblerExitCodes::kAssemblyDirCreationError);
-      }
-    }
+    EnsureDirectoryExistsOrExit(
+        FLAGS_assembly_dir, AssemblerExitCodes::kAssemblyDirCreationError);
     if (log->LinkAtCwd(config.AssemblyPath("assemble_cvd.log"))) {
       LOG(ERROR) << "Unable to persist assemble_cvd log at "
                   << config.AssemblyPath("assemble_cvd.log")
                   << ": " << log->StrError();
     }
     std::string disk_hole_dir = FLAGS_assembly_dir + "/disk_hole";
-    if (!cuttlefish::DirectoryExists(disk_hole_dir.c_str())) {
-      LOG(DEBUG) << "Setting up " << disk_hole_dir << "/disk_hole";
-      if (mkdir(disk_hole_dir.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
-          && errno != EEXIST) {
-        LOG(ERROR) << "Failed to create assembly directory: "
-                  << disk_hole_dir << ". Error: " << errno;
-        exit(AssemblerExitCodes::kAssemblyDirCreationError);
-      }
-    }
+    EnsureDirectoryExistsOrExit(
+        disk_hole_dir, cuttlefish::kAssemblyDirCreationError);
     for (const auto& instance : config.Instances()) {
       // Create instance directory if it doesn't exist.
-      if (!cuttlefish::DirectoryExists(instance.instance_dir().c_str())) {
-        LOG(DEBUG) << "Setting up " << FLAGS_instance_dir << ".N";
-        if (mkdir(instance.instance_dir().c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
-            && errno != EEXIST) {
-          LOG(ERROR) << "Failed to create instance directory: "
-                    << FLAGS_instance_dir << ". Error: " << errno;
-          exit(AssemblerExitCodes::kInstanceDirCreationError);
-        }
-      }
+      EnsureDirectoryExistsOrExit(
+          instance.instance_dir(), cuttlefish::kInstanceDirCreationError);
       auto internal_dir = instance.instance_dir() + "/" + cuttlefish::kInternalDirName;
-      if (!cuttlefish::DirectoryExists(internal_dir)) {
-        if (mkdir(internal_dir.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
-           && errno != EEXIST) {
-          LOG(ERROR) << "Failed to create internal instance directory: "
-                    << internal_dir << ". Error: " << errno;
-          exit(AssemblerExitCodes::kInstanceDirCreationError);
-        }
-      }
+      EnsureDirectoryExistsOrExit(
+          internal_dir, cuttlefish::kInstanceDirCreationError);
       auto shared_dir = instance.instance_dir() + "/" + cuttlefish::kSharedDirName;
-      if (!cuttlefish::DirectoryExists(shared_dir)) {
-         if (mkdir(shared_dir.c_str(), S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH) < 0
-           && errno != EEXIST) {
-          LOG(ERROR) << "Failed to create shared instance directory: "
-                    << shared_dir << ". Error: " << errno;
-          exit(AssemblerExitCodes::kInstanceDirCreationError);
-        }
-      }
+      EnsureDirectoryExistsOrExit(
+          shared_dir, cuttlefish::kInstanceDirCreationError);
     }
     if (!SaveConfig(config)) {
       LOG(ERROR) << "Failed to initialize configuration";
@@ -1065,116 +989,6 @@
   return config.AssemblyPath("cuttlefish_config.json");
 }
 
-std::optional<IfaceConfig> AcquireIfaces(int num) {
-  IfaceConfig config{};
-  if (!FLAGS_use_allocd) {
-    config.mobile_tap.name = StrForInstance("cvd-mtap-", num);
-    config.mobile_tap.resource_id = 0;
-    config.mobile_tap.session_id = 0;
-
-    config.wireless_tap.name = StrForInstance("cvd-wtap-", num);
-    config.wireless_tap.resource_id = 0;
-    config.wireless_tap.session_id = 0;
-    return config;
-  }
-  return RequestIfaces();
-}
-
-std::optional<IfaceConfig> RequestIfaces() {
-  IfaceConfig config{};
-
-  cuttlefish::SharedFD allocd_sock = cuttlefish::SharedFD::SocketLocalClient(
-      cuttlefish::kDefaultLocation, false, SOCK_STREAM);
-  if (!allocd_sock->IsOpen()) {
-    LOG(FATAL) << "Unable to connect to allocd on "
-               << cuttlefish::kDefaultLocation << ": "
-               << allocd_sock->StrError();
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-
-  Json::Value resource_config;
-  Json::Value request_list;
-  Json::Value req;
-  req["request_type"] = "create_interface";
-  req["uid"] = geteuid();
-  req["iface_type"] = "mtap";
-  request_list.append(req);
-  req["iface_type"] = "wtap";
-  request_list.append(req);
-
-  resource_config["config_request"]["request_list"] = request_list;
-
-  if (!cuttlefish::SendJsonMsg(allocd_sock, resource_config)) {
-    LOG(FATAL) << "Failed to send JSON to allocd\n";
-    return std::nullopt;
-  }
-
-  auto resp_opt = cuttlefish::RecvJsonMsg(allocd_sock);
-  if (!resp_opt.has_value()) {
-    LOG(FATAL) << "Bad Response from allocd\n";
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-  auto resp = resp_opt.value();
-
-  if (!resp.isMember("config_status") || !resp["config_status"].isString()) {
-    LOG(FATAL) << "Bad response from allocd: " << resp;
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-
-  if (resp["config_status"].asString() !=
-      cuttlefish::StatusToStr(cuttlefish::RequestStatus::Success)) {
-    LOG(FATAL) << "Failed to allocate interfaces " << resp;
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-
-  if (!resp.isMember("session_id") || !resp["session_id"].isUInt()) {
-    LOG(FATAL) << "Bad response from allocd: " << resp;
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-  auto session_id = resp["session_id"].asUInt();
-
-  if (!resp.isMember("response_list") || !resp["response_list"].isArray()) {
-    LOG(FATAL) << "Bad response from allocd: " << resp;
-    exit(cuttlefish::kAllocdConnectionError);
-  }
-
-  Json::Value resp_list = resp["response_list"];
-  Json::Value mtap_resp;
-  Json::Value wifi_resp;
-  for (Json::Value::ArrayIndex i = 0; i != resp_list.size(); ++i) {
-    auto ty = cuttlefish::StrToIfaceTy(resp_list[i]["iface_type"].asString());
-
-    switch (ty) {
-      case cuttlefish::IfaceType::mtap: {
-        mtap_resp = resp_list[i];
-        break;
-      }
-      case cuttlefish::IfaceType::wtap: {
-        wifi_resp = resp_list[i];
-        break;
-      }
-      default: {
-        break;
-      }
-    }
-  }
-
-  if (!mtap_resp.isMember("iface_type")) {
-    LOG(ERROR) << "Missing mtap response from allocd";
-    return std::nullopt;
-  }
-  if (!wifi_resp.isMember("iface_type")) {
-    LOG(ERROR) << "Missing wtap response from allocd";
-    return std::nullopt;
-  }
-
-  config.mobile_tap.name = mtap_resp["iface_name"].asString();
-  config.mobile_tap.resource_id = mtap_resp["resource_id"].asUInt();
-  config.mobile_tap.session_id = session_id;
-
-  config.wireless_tap.name = wifi_resp["iface_name"].asString();
-  config.wireless_tap.resource_id = wifi_resp["resource_id"].asUInt();
-  config.wireless_tap.session_id = session_id;
-
-  return config;
+std::string GetCuttlefishEnvPath() {
+  return cuttlefish::StringFromEnv("HOME", ".") + "/.cuttlefish.sh";
 }
diff --git a/host/commands/assemble_cvd/flags.h b/host/commands/assemble_cvd/flags.h
index 417ee49..7322854 100644
--- a/host/commands/assemble_cvd/flags.h
+++ b/host/commands/assemble_cvd/flags.h
@@ -9,19 +9,4 @@
 const cuttlefish::CuttlefishConfig* InitFilesystemAndCreateConfig(
     int* argc, char*** argv, cuttlefish::FetcherConfig config);
 std::string GetConfigFilePath(const cuttlefish::CuttlefishConfig& config);
-
-struct IfaceData {
-  std::string name;
-  uint32_t session_id;
-  uint32_t resource_id;
-};
-
-struct IfaceConfig {
-  IfaceData mobile_tap;
-  IfaceData wireless_tap;
-};
-
-// Acquires interfaces from the resource allocator daemon if it is enabled, 
-// or fallse back to using the static resources created by the debian package
-std::optional<IfaceConfig> AcquireIfaces(int num);
-std::optional<IfaceConfig> RequestIfaces();
+std::string GetCuttlefishEnvPath();
diff --git a/host/commands/kernel_log_monitor/kernel_log_server.cc b/host/commands/kernel_log_monitor/kernel_log_server.cc
index 089706f..66fafab 100644
--- a/host/commands/kernel_log_monitor/kernel_log_server.cc
+++ b/host/commands/kernel_log_monitor/kernel_log_server.cc
@@ -41,6 +41,7 @@
     {cuttlefish::kMobileNetworkConnectedMessage,
      monitor::Event::MobileNetworkConnected},
     {cuttlefish::kWifiConnectedMessage, monitor::Event::WifiNetworkConnected},
+    {cuttlefish::kEthernetConnectedMessage, monitor::Event::EthernetNetworkConnected},
     // TODO(b/131864854): Replace this with a string less likely to change
     {"init: starting service 'adbd'...", monitor::Event::AdbdStarted},
     {cuttlefish::kScreenChangedMessage, monitor::Event::ScreenChanged},
diff --git a/host/commands/kernel_log_monitor/kernel_log_server.h b/host/commands/kernel_log_monitor/kernel_log_server.h
index 13e6a8a..659a372 100644
--- a/host/commands/kernel_log_monitor/kernel_log_server.h
+++ b/host/commands/kernel_log_monitor/kernel_log_server.h
@@ -36,6 +36,7 @@
   MobileNetworkConnected = 4,
   AdbdStarted = 5,
   ScreenChanged = 6,
+  EthernetNetworkConnected = 7,
 };
 
 enum class SubscriptionAction {
diff --git a/host/commands/run_cvd/launch.cc b/host/commands/run_cvd/launch.cc
index 26c01e6..b45fe58 100644
--- a/host/commands/run_cvd/launch.cc
+++ b/host/commands/run_cvd/launch.cc
@@ -4,6 +4,7 @@
 #include <sys/types.h>
 
 #include <android-base/logging.h>
+#include <android-base/strings.h>
 
 #include "common/libs/fs/shared_fd.h"
 #include "common/libs/utils/files.h"
@@ -311,6 +312,8 @@
 
   webrtc.AddParameter("-kernel_log_events_fd=", kernel_log_events_pipe);
 
+  LaunchCustomActionServers(webrtc, process_monitor, config);
+
   // TODO get from launcher params
   process_monitor->StartSubprocess(std::move(webrtc),
                                    GetOnSubprocessExitCallback(config));
@@ -530,6 +533,40 @@
                                    GetOnSubprocessExitCallback(config));
 }
 
+void LaunchCustomActionServers(cuttlefish::Command& webrtc_cmd,
+                               cuttlefish::ProcessMonitor* process_monitor,
+                               const cuttlefish::CuttlefishConfig& config) {
+  bool first = true;
+  for (const auto& custom_action : config.custom_actions()) {
+    if (custom_action.server) {
+      // Create a socket pair that will be used for communication between
+      // WebRTC and the action server.
+      cuttlefish::SharedFD webrtc_socket, action_server_socket;
+      if (!cuttlefish::SharedFD::SocketPair(AF_LOCAL, SOCK_STREAM, 0,
+                                            &webrtc_socket, &action_server_socket)) {
+        LOG(ERROR) << "Unable to create custom action server socket pair: "
+                   << strerror(errno);
+        continue;
+      }
+
+      // Launch the action server, providing its socket pair fd as the only argument.
+      std::string binary = "bin/" + *(custom_action.server);
+      cuttlefish::Command command(cuttlefish::DefaultHostArtifactsPath(binary));
+      command.AddParameter(action_server_socket);
+      process_monitor->StartSubprocess(std::move(command),
+                                       GetOnSubprocessExitCallback(config));
+
+      // Pass the WebRTC socket pair fd to WebRTC.
+      if (first) {
+        first = false;
+        webrtc_cmd.AddParameter("-action_servers=", *custom_action.server, ":", webrtc_socket);
+      } else {
+        webrtc_cmd.AppendToLastParameter(",", *custom_action.server, ":", webrtc_socket);
+      }
+    }
+  }
+}
+
 void LaunchVerhicleHalServerIfEnabled(const cuttlefish::CuttlefishConfig& config,
                                                         cuttlefish::ProcessMonitor* process_monitor) {
     if (!config.enable_vehicle_hal_grpc_server() ||
diff --git a/host/commands/run_cvd/launch.h b/host/commands/run_cvd/launch.h
index a727c11..d432fa8 100644
--- a/host/commands/run_cvd/launch.h
+++ b/host/commands/run_cvd/launch.h
@@ -46,6 +46,10 @@
 void LaunchSecureEnvironment(cuttlefish::ProcessMonitor* process_monitor,
                              const cuttlefish::CuttlefishConfig& config);
 
+void LaunchCustomActionServers(cuttlefish::Command& webrtc_cmd,
+                               cuttlefish::ProcessMonitor* process_monitor,
+                               const cuttlefish::CuttlefishConfig& config);
+
 void LaunchVerhicleHalServerIfEnabled(const cuttlefish::CuttlefishConfig& config,
                                       cuttlefish::ProcessMonitor* process_monitor);
 
diff --git a/host/commands/run_cvd/main.cc b/host/commands/run_cvd/main.cc
index b0b73f4..9b464c3 100644
--- a/host/commands/run_cvd/main.cc
+++ b/host/commands/run_cvd/main.cc
@@ -500,6 +500,9 @@
   } else if (used_tap_devices.count(instance.mobile_tap_name())) {
     LOG(ERROR) << "Mobile TAP device already in use";
     return RunnerExitCodes::kTapDeviceInUse;
+  } else if (config->ethernet() &&
+             used_tap_devices.count(instance.ethernet_tap_name())) {
+    LOG(ERROR) << "Ethernet TAP device already in use";
   }
 
   auto vm_manager = GetVmManager(config->vm_manager());
diff --git a/host/example_custom_actions/Android.bp b/host/example_custom_actions/Android.bp
new file mode 100644
index 0000000..65b3ca4
--- /dev/null
+++ b/host/example_custom_actions/Android.bp
@@ -0,0 +1,24 @@
+cc_binary_host {
+    name: "cuttlefish_example_action_server",
+    srcs: ["main.cpp"],
+    defaults: [
+        "cuttlefish_host_only",
+    ],
+    shared_libs: [
+        "libbase",
+        "liblog",
+        "libutils",
+        "libjsoncpp",
+        "libcuttlefish_fs",
+        "libcuttlefish_utils",
+    ],
+    static_libs: [
+        "libcuttlefish_host_config",
+    ],
+}
+
+prebuilt_etc_host {
+    name: "cuttlefish_example_action_config.json",
+    src: "custom_action_config.json",
+    sub_dir: "cvd_custom_action_config",
+}
diff --git a/host/example_custom_actions/README.md b/host/example_custom_actions/README.md
new file mode 100644
index 0000000..de3764d
--- /dev/null
+++ b/host/example_custom_actions/README.md
@@ -0,0 +1,12 @@
+To try out the custom action config and action server in this path, set the
+following build vars:
+
+```
+SOONG_CONFIG_NAMESPACES += cvd
+SOONG_CONFIG_cvd += custom_action_config custom_action_servers
+
+SOONG_CONFIG_cvd_custom_action_config := cuttlefish_example_action_config.json
+SOONG_CONFIG_cvd_custom_action_servers += cuttlefish_example_action_server
+```
+
+See `device/google/cuttlefish/build/README.md` for more information.
diff --git a/host/example_custom_actions/custom_action_config.json b/host/example_custom_actions/custom_action_config.json
new file mode 100644
index 0000000..56dae0d
--- /dev/null
+++ b/host/example_custom_actions/custom_action_config.json
@@ -0,0 +1,25 @@
+[
+	{
+		"shell_command":"am start -a android.intent.action.VIEW -d https://www.android.com/",
+		"button":{
+			"command":"web",
+			"title":"Web Page",
+			"icon_name":"language"
+		}
+	},
+	{
+		"server":"cuttlefish_example_action_server",
+		"buttons":[
+			{
+				"command":"settings",
+				"title":"Quick Settings",
+				"icon_name":"settings"
+			},
+			{
+				"command":"alert",
+				"title":"Do Not Disturb",
+				"icon_name":"notifications_paused"
+			}
+		]
+	}
+]
diff --git a/host/example_custom_actions/main.cpp b/host/example_custom_actions/main.cpp
new file mode 100644
index 0000000..3f2c8d9
--- /dev/null
+++ b/host/example_custom_actions/main.cpp
@@ -0,0 +1,78 @@
+#include <android-base/logging.h>
+#include <android-base/strings.h>
+#include <sys/socket.h>
+
+#include "common/libs/fs/shared_buf.h"
+#include "common/libs/fs/shared_fd.h"
+#include "host/libs/config/cuttlefish_config.h"
+
+// Messages are always 128 bytes.
+#define MESSAGE_SIZE 128
+
+using cuttlefish::SharedFD;
+
+int main(int argc, char** argv) {
+  if (argc <= 1) {
+    return 1;
+  }
+
+  // Connect to WebRTC
+  int fd = std::atoi(argv[1]);
+  LOG(INFO) << "Connecting to WebRTC server...";
+  SharedFD webrtc_socket = SharedFD::Dup(fd);
+  close(fd);
+  if (webrtc_socket->IsOpen()) {
+    LOG(INFO) << "Connected";
+  } else {
+    LOG(ERROR) << "Could not connect, exiting...";
+    return 1;
+  }
+
+  // Track state for our two commands.
+  bool statusbar_expanded = false;
+  bool dnd_on = false;
+
+  char buf[MESSAGE_SIZE];
+  while (1) {
+    // Read the command message from the socket.
+    if (!webrtc_socket->IsOpen()) {
+      LOG(WARNING) << "WebRTC was closed.";
+      break;
+    }
+    if (cuttlefish::ReadExact(webrtc_socket, buf, MESSAGE_SIZE) !=
+        MESSAGE_SIZE) {
+      LOG(WARNING) << "Failed to read the correct number of bytes.";
+      break;
+    }
+    auto split = android::base::Split(buf, ":");
+    std::string command = split[0];
+    std::string state = split[1];
+
+    // Ignore button-release events, when state != down.
+    if (state != "down") {
+      continue;
+    }
+
+    // Demonstrate two commands. For demonstration purposes these two
+    // commands use adb shell, but commands can execute any action you choose.
+    std::string adb_shell_command =
+        cuttlefish::DefaultHostArtifactsPath("bin/adb");
+    if (command == "settings") {
+      adb_shell_command += " shell cmd statusbar ";
+      adb_shell_command += statusbar_expanded ? "collapse" : "expand-settings";
+      statusbar_expanded = !statusbar_expanded;
+    } else if (command == "alert") {
+      adb_shell_command += " shell cmd notification set_dnd ";
+      adb_shell_command += dnd_on ? "off" : "on";
+      dnd_on = !dnd_on;
+    } else {
+      LOG(WARNING) << "Unexpected command: " << buf;
+    }
+
+    if (!adb_shell_command.empty()) {
+      if (system(adb_shell_command.c_str()) != 0) {
+        LOG(ERROR) << "Failed to run command: " << adb_shell_command;
+      }
+    }
+  }
+}
diff --git a/host/frontend/vnc_server/frame_buffer_watcher.cpp b/host/frontend/vnc_server/frame_buffer_watcher.cpp
index f195821..a8fd72f 100644
--- a/host/frontend/vnc_server/frame_buffer_watcher.cpp
+++ b/host/frontend/vnc_server/frame_buffer_watcher.cpp
@@ -190,3 +190,11 @@
 int FrameBufferWatcher::StripesPerFrame() {
   return SimulatedHWComposer::NumberOfStripes();
 }
+
+void FrameBufferWatcher::IncClientCount() {
+  hwcomposer.ReportClientsConnected();
+}
+
+void FrameBufferWatcher::DecClientCount() {
+  // Do nothing
+}
diff --git a/host/frontend/vnc_server/frame_buffer_watcher.h b/host/frontend/vnc_server/frame_buffer_watcher.h
index 909f092..d147642 100644
--- a/host/frontend/vnc_server/frame_buffer_watcher.h
+++ b/host/frontend/vnc_server/frame_buffer_watcher.h
@@ -38,6 +38,9 @@
 
   StripePtrVec StripesNewerThan(ScreenOrientation orientation,
                                 const SeqNumberVec& seq_num) const;
+  void IncClientCount();
+  void DecClientCount();
+
   static int StripesPerFrame();
 
  private:
diff --git a/host/frontend/vnc_server/simulated_hw_composer.cpp b/host/frontend/vnc_server/simulated_hw_composer.cpp
index 811151e..32aa387 100644
--- a/host/frontend/vnc_server/simulated_hw_composer.cpp
+++ b/host/frontend/vnc_server/simulated_hw_composer.cpp
@@ -126,3 +126,7 @@
 }
 
 int SimulatedHWComposer::NumberOfStripes() { return kNumStripes; }
+
+void SimulatedHWComposer::ReportClientsConnected() {
+  screen_connector_->ReportClientsConnected(true);
+}
diff --git a/host/frontend/vnc_server/simulated_hw_composer.h b/host/frontend/vnc_server/simulated_hw_composer.h
index 1b6cf87..e0da859 100644
--- a/host/frontend/vnc_server/simulated_hw_composer.h
+++ b/host/frontend/vnc_server/simulated_hw_composer.h
@@ -39,6 +39,8 @@
 
   Stripe GetNewStripe();
 
+  void ReportClientsConnected();
+
   // NOTE not constexpr on purpose
   static int NumberOfStripes();
 
diff --git a/host/frontend/vnc_server/vnc_server.cpp b/host/frontend/vnc_server/vnc_server.cpp
index beb227f..dd489b8 100644
--- a/host/frontend/vnc_server/vnc_server.cpp
+++ b/host/frontend/vnc_server/vnc_server.cpp
@@ -53,7 +53,9 @@
   // data members. In the current setup, if the VncServer is destroyed with
   // clients still running, the clients will all be left with dangling
   // pointers.
+  frame_buffer_watcher_.IncClientCount();
   VncClientConnection client(std::move(sock), virtual_inputs_, &bb_,
                              aggressive_);
   client.StartSession();
+  frame_buffer_watcher_.DecClientCount();
 }
diff --git a/host/frontend/webrtc/connection_observer.cpp b/host/frontend/webrtc/connection_observer.cpp
index 3ab1cf8..2a22ec0 100644
--- a/host/frontend/webrtc/connection_observer.cpp
+++ b/host/frontend/webrtc/connection_observer.cpp
@@ -18,6 +18,7 @@
 
 #include <linux/input.h>
 
+#include <map>
 #include <thread>
 #include <vector>
 
@@ -81,16 +82,25 @@
  public:
   ConnectionObserverImpl(cuttlefish::InputSockets& input_sockets,
                          cuttlefish::SharedFD kernel_log_events_fd,
+                         std::map<std::string, cuttlefish::SharedFD>
+                             commands_to_custom_action_servers,
                          std::weak_ptr<DisplayHandler> display_handler)
       : input_sockets_(input_sockets),
         kernel_log_events_client_(kernel_log_events_fd),
+        commands_to_custom_action_servers_(commands_to_custom_action_servers),
         weak_display_handler_(display_handler) {}
-  virtual ~ConnectionObserverImpl() = default;
+  virtual ~ConnectionObserverImpl() {
+    auto display_handler = weak_display_handler_.lock();
+    if (display_handler) {
+      display_handler->DecClientCount();
+    }
+  }
 
   void OnConnected(std::function<void(const uint8_t *, size_t, bool)>
                    /*ctrl_msg_sender*/) override {
     auto display_handler = weak_display_handler_.lock();
     if (display_handler) {
+      display_handler->IncClientCount();
       // A long time may pass before the next frame comes up from the guest.
       // Send the last one to avoid showing a black screen to the user during
       // that time.
@@ -181,6 +191,15 @@
       OnKeyboardEvent(KEY_VOLUMEDOWN, state == "down");
     } else if (command == "volumeup") {
       OnKeyboardEvent(KEY_VOLUMEUP, state == "down");
+    } else if (commands_to_custom_action_servers_.find(command) !=
+               commands_to_custom_action_servers_.end()) {
+      // Simple protocol for commands forwarded to action servers:
+      //   - Always 128 bytes
+      //   - Format:   command:state
+      //   - Example:  my_button:down
+      std::string action_server_message = command + ":" + state;
+      cuttlefish::WriteAll(commands_to_custom_action_servers_[command],
+                           action_server_message.c_str(), 128);
     } else {
       LOG(WARNING) << "Unsupported control command: " << command << " (" << state << ")";
       // TODO(b/163081337): Handle custom commands.
@@ -192,6 +211,7 @@
   cuttlefish::SharedFD kernel_log_events_client_;
   std::shared_ptr<cuttlefish::webrtc_streaming::AdbHandler> adb_handler_;
   std::shared_ptr<cuttlefish::webrtc_streaming::KernelLogEventsHandler> kernel_log_events_handler_;
+  std::map<std::string, cuttlefish::SharedFD> commands_to_custom_action_servers_;
   std::weak_ptr<DisplayHandler> weak_display_handler_;
 };
 
@@ -206,9 +226,19 @@
   return std::shared_ptr<cuttlefish::webrtc_streaming::ConnectionObserver>(
       new ConnectionObserverImpl(input_sockets_,
                                  kernel_log_events_fd_,
+                                 commands_to_custom_action_servers_,
                                  weak_display_handler_));
 }
 
+void CfConnectionObserverFactory::AddCustomActionServer(
+    cuttlefish::SharedFD custom_action_server_fd,
+    const std::vector<std::string>& commands) {
+  for (const std::string& command : commands) {
+    LOG(DEBUG) << "Action server is listening to command: " << command;
+    commands_to_custom_action_servers_[command] = custom_action_server_fd;
+  }
+}
+
 void CfConnectionObserverFactory::SetDisplayHandler(
     std::weak_ptr<DisplayHandler> display_handler) {
   weak_display_handler_ = display_handler;
diff --git a/host/frontend/webrtc/connection_observer.h b/host/frontend/webrtc/connection_observer.h
index e2c185a..3d12807 100644
--- a/host/frontend/webrtc/connection_observer.h
+++ b/host/frontend/webrtc/connection_observer.h
@@ -16,6 +16,7 @@
 
 #pragma once
 
+#include <map>
 #include <memory>
 
 #include "common/libs/fs/shared_fd.h"
@@ -41,11 +42,16 @@
   std::shared_ptr<cuttlefish::webrtc_streaming::ConnectionObserver> CreateObserver()
       override;
 
+  void AddCustomActionServer(cuttlefish::SharedFD custom_action_server_fd,
+                             const std::vector<std::string>& commands);
+
   void SetDisplayHandler(std::weak_ptr<DisplayHandler> display_handler);
 
  private:
   cuttlefish::InputSockets& input_sockets_;
   cuttlefish::SharedFD kernel_log_events_fd_;
+  std::map<std::string, cuttlefish::SharedFD>
+      commands_to_custom_action_servers_;
   std::weak_ptr<DisplayHandler> weak_display_handler_;
 };
 
diff --git a/host/frontend/webrtc/display_handler.cpp b/host/frontend/webrtc/display_handler.cpp
index a185613..256b96b 100644
--- a/host/frontend/webrtc/display_handler.cpp
+++ b/host/frontend/webrtc/display_handler.cpp
@@ -76,4 +76,18 @@
   }
 }
 
+void DisplayHandler::IncClientCount() {
+  client_count_++;
+  if (client_count_ == 1) {
+    screen_connector_->ReportClientsConnected(true);
+  }
+}
+
+void DisplayHandler::DecClientCount() {
+  client_count_--;
+  if (client_count_ == 0) {
+    screen_connector_->ReportClientsConnected(false);
+  }
+}
+
 }  // namespace cuttlefish
diff --git a/host/frontend/webrtc/display_handler.h b/host/frontend/webrtc/display_handler.h
index 236c26a..b87cb57 100644
--- a/host/frontend/webrtc/display_handler.h
+++ b/host/frontend/webrtc/display_handler.h
@@ -33,11 +33,15 @@
   [[noreturn]] void Loop();
   void SendLastFrame();
 
+  void IncClientCount();
+  void DecClientCount();
+
  private:
   std::shared_ptr<webrtc_streaming::VideoSink> display_sink_;
   ScreenConnector* screen_connector_;
   std::shared_ptr<webrtc_streaming::VideoFrameBuffer> last_buffer_;
   std::mutex last_buffer_mutex_;
   std::mutex next_frame_mutex_;
+  int client_count_ = 0;
 };
 }  // namespace cuttlefish
diff --git a/host/frontend/webrtc/lib/streamer.cpp b/host/frontend/webrtc/lib/streamer.cpp
index afa3a97..c8c3585 100644
--- a/host/frontend/webrtc/lib/streamer.cpp
+++ b/host/frontend/webrtc/lib/streamer.cpp
@@ -51,6 +51,11 @@
 constexpr auto kCpusField = "cpus";
 constexpr auto kMemoryMbField = "memory_mb";
 constexpr auto kHardwareField = "hardware";
+constexpr auto kControlPanelButtonCommand = "command";
+constexpr auto kControlPanelButtonTitle = "title";
+constexpr auto kControlPanelButtonIconName = "icon_name";
+constexpr auto kControlPanelButtonShellCommand = "shell_command";
+constexpr auto kCustomControlPanelButtonsField = "custom_control_panel_buttons";
 
 void SendJson(WsConnection* ws_conn, const Json::Value& data) {
   Json::FastWriter json_writer;
@@ -92,6 +97,13 @@
   int memory_mb;
 };
 
+struct ControlPanelButtonDescriptor {
+  std::string command;
+  std::string title;
+  std::string icon_name;
+  std::optional<std::string> shell_command;
+};
+
 // TODO (jemoreira): move to a place in common with the signaling server
 struct OperatorServerConfig {
   std::vector<webrtc::PeerConnectionInterface::IceServer> servers;
@@ -130,6 +142,7 @@
   std::map<int, std::shared_ptr<ClientHandler>> clients_;
   std::weak_ptr<OperatorObserver> operator_observer_;
   HardwareDescriptor hardware_;
+  std::vector<ControlPanelButtonDescriptor> custom_control_panel_buttons_;
 };
 
 Streamer::Streamer(std::unique_ptr<Streamer::Impl> impl)
@@ -203,6 +216,15 @@
   impl_->hardware_.memory_mb = memory_mb;
 }
 
+void Streamer::AddCustomControlPanelButton(
+    const std::string& command, const std::string& title,
+    const std::string& icon_name,
+    const std::optional<std::string>& shell_command) {
+  ControlPanelButtonDescriptor button = {command, title, icon_name,
+                                         shell_command};
+  impl_->custom_control_panel_buttons_.push_back(button);
+}
+
 void Streamer::AddAudio(const std::string& label) {
   // Usually called from an application thread
   // TODO (b/128328845): audio support. Use signal_thread_->Invoke<>();
@@ -263,6 +285,18 @@
     hardware[kCpusField] = hardware_.cpus;
     hardware[kMemoryMbField] = hardware_.memory_mb;
     device_info[kHardwareField] = hardware;
+    Json::Value custom_control_panel_buttons(Json::arrayValue);
+    for (const auto& button : custom_control_panel_buttons_) {
+      Json::Value button_entry;
+      button_entry[kControlPanelButtonCommand] = button.command;
+      button_entry[kControlPanelButtonTitle] = button.title;
+      button_entry[kControlPanelButtonIconName] = button.icon_name;
+      if (button.shell_command) {
+        button_entry[kControlPanelButtonShellCommand] = *(button.shell_command);
+      }
+      custom_control_panel_buttons.append(button_entry);
+    }
+    device_info[kCustomControlPanelButtonsField] = custom_control_panel_buttons;
     register_obj[cuttlefish::webrtc_signaling::kDeviceInfoField] = device_info;
     SendJson(server_connection_.get(), register_obj);
     // Do this last as OnRegistered() is user code and may take some time to
diff --git a/host/frontend/webrtc/lib/streamer.h b/host/frontend/webrtc/lib/streamer.h
index 7788dc7..f39d804 100644
--- a/host/frontend/webrtc/lib/streamer.h
+++ b/host/frontend/webrtc/lib/streamer.h
@@ -19,6 +19,7 @@
 #include <functional>
 #include <memory>
 #include <mutex>
+#include <optional>
 #include <string>
 #include <utility>
 #include <vector>
@@ -80,6 +81,14 @@
 
   void SetHardwareSpecs(int cpus, int memory_mb);
 
+  // Add a custom button to the control panel.
+  //   If this button should be handled by an action server, use nullopt (the
+  //   default) for shell_command.
+  void AddCustomControlPanelButton(
+      const std::string& command, const std::string& title,
+      const std::string& icon_name,
+      const std::optional<std::string>& shell_command = std::nullopt);
+
   // TODO (b/128328845): Implement audio, return a shared_ptr to a class
   // equivalent to webrtc::AudioSinkInterface.
   void AddAudio(const std::string& label);
diff --git a/host/frontend/webrtc/main.cpp b/host/frontend/webrtc/main.cpp
index d772a84..85c6660 100644
--- a/host/frontend/webrtc/main.cpp
+++ b/host/frontend/webrtc/main.cpp
@@ -22,6 +22,7 @@
 #include <vector>
 
 #include <android-base/logging.h>
+#include <android-base/strings.h>
 #include <gflags/gflags.h>
 #include <libyuv.h>
 
@@ -38,6 +39,9 @@
 DEFINE_int32(keyboard_fd, -1, "An fd to listen on for keyboard connections.");
 DEFINE_int32(frame_server_fd, -1, "An fd to listen on for frame updates");
 DEFINE_int32(kernel_log_events_fd, -1, "An fd to listen on for kernel log events.");
+DEFINE_string(action_servers, "",
+              "A comma-separated list of server_name:fd pairs, "
+              "where each entry corresponds to one custom action server.");
 DEFINE_bool(write_virtio_input, false,
             "Whether to send input events in virtio format.");
 
@@ -174,6 +178,60 @@
 
   streamer->SetHardwareSpecs(cvd_config->cpus(), cvd_config->memory_mb());
 
+  // Parse the -action_servers flag, storing a map of action server name -> fd
+  std::map<std::string, int> action_server_fds;
+  for (const std::string& action_server : android::base::Split(FLAGS_action_servers, ",")) {
+    if (action_server.empty()) {
+      continue;
+    }
+    const std::vector<std::string> server_and_fd = android::base::Split(action_server, ":");
+    CHECK(server_and_fd.size() == 2) << "Wrong format for action server flag: " << action_server;
+    std::string server = server_and_fd[0];
+    int fd = std::stoi(server_and_fd[1]);
+    action_server_fds[server] = fd;
+  }
+
+  for (const auto& custom_action : cvd_config->custom_actions()) {
+    if (custom_action.shell_command) {
+      if (custom_action.buttons.size() != 1) {
+        LOG(FATAL) << "Expected exactly one button for custom action command: "
+                   << *(custom_action.shell_command);
+      }
+      const auto button = custom_action.buttons[0];
+      streamer->AddCustomControlPanelButton(button.command, button.title,
+                                            button.icon_name,
+                                            custom_action.shell_command);
+    }
+    if (custom_action.server) {
+      if (action_server_fds.find(*(custom_action.server)) !=
+          action_server_fds.end()) {
+        LOG(INFO) << "Connecting to custom action server "
+                  << *(custom_action.server);
+
+        int fd = action_server_fds[*(custom_action.server)];
+        cuttlefish::SharedFD custom_action_server = cuttlefish::SharedFD::Dup(fd);
+        close(fd);
+
+        if (custom_action_server->IsOpen()) {
+          std::vector<std::string> commands_for_this_server;
+          for (const auto& button : custom_action.buttons) {
+            streamer->AddCustomControlPanelButton(button.command, button.title,
+                                                  button.icon_name);
+            commands_for_this_server.push_back(button.command);
+          }
+          observer_factory->AddCustomActionServer(custom_action_server,
+                                                  commands_for_this_server);
+        } else {
+          LOG(ERROR) << "Error connecting to custom action server: "
+                     << *(custom_action.server);
+        }
+      } else {
+        LOG(ERROR) << "Custom action server not provided as command line flag: "
+                   << *(custom_action.server);
+      }
+    }
+  }
+
   std::shared_ptr<cuttlefish::webrtc_streaming::OperatorObserver> operator_observer(
       new CfOperatorObserver());
   streamer->Register(operator_observer);
diff --git a/host/frontend/webrtc_operator/assets/index.html b/host/frontend/webrtc_operator/assets/index.html
index 972bba6..2b15302 100644
--- a/host/frontend/webrtc_operator/assets/index.html
+++ b/host/frontend/webrtc_operator/assets/index.html
@@ -36,6 +36,9 @@
           <hr>
           <div id='control_panel'>
             <h2>Device Controls</h2>
+            <div id='control_panel_default_buttons'></div>
+            <h2 id='custom_controls_title'>Custom Controls</h2>
+            <div id='control_panel_custom_buttons'></div>
           </div>
           <div id='device_details'>
             <h2>Device Details</h2>
diff --git a/host/frontend/webrtc_operator/assets/js/app.js b/host/frontend/webrtc_operator/assets/js/app.js
index b2e6293..b1f97c9 100644
--- a/host/frontend/webrtc_operator/assets/js/app.js
+++ b/host/frontend/webrtc_operator/assets/js/app.js
@@ -112,9 +112,10 @@
   window.onresize = resizeDeviceView;
 
   function createControlPanelButton(command, title, icon_name,
-      listener=onControlPanelButton) {
+      listener=onControlPanelButton,
+      parent_id='control_panel_default_buttons') {
     let button = document.createElement('button');
-    document.getElementById('control_panel').appendChild(button);
+    document.getElementById(parent_id).appendChild(button);
     button.title = title;
     button.dataset.command = command;
     // Capture mousedown/up/out commands instead of click to enable
@@ -158,6 +159,23 @@
       startMouseTracking();  // TODO stopMouseTracking() when disconnected
       updateDeviceHardwareDetails(deviceConnection.description.hardware);
       updateDeviceDisplayDetails(deviceConnection.description.displays[0]);
+      if (deviceConnection.description.custom_control_panel_buttons.length == 0) {
+        document.getElementById('custom_controls_title').style.visibility = 'hidden';
+      } else {
+        for (const button of deviceConnection.description.custom_control_panel_buttons) {
+          if (button.shell_command) {
+            // This button's command is handled by sending an ADB shell command.
+            createControlPanelButton(button.command, button.title, button.icon_name,
+                e => onCustomShellButton(button.shell_command, e),
+                'control_panel_custom_buttons');
+          } else {
+            // This button's command is handled by custom action server.
+            createControlPanelButton(button.command, button.title, button.icon_name,
+                onControlPanelButton,
+                'control_panel_custom_buttons');
+          }
+        }
+      }
       deviceConnection.onControlMessage(msg => onControlMessage(msg));
       // Start the screen as hidden. Only show when data is ready.
       deviceScreen.style.visibility = 'hidden';
@@ -219,6 +237,14 @@
       adbShell('/vendor/bin/cuttlefish_rotate ' + (rotation == 0 ? 'landscape' : 'portrait'))
     }
   }
+  function onCustomShellButton(shell_command, e) {
+    // Attempt to init adb again, in case the initial connection failed.
+    // This succeeds immediately if already connected.
+    init_adb(deviceConnection);
+    if (e.type == 'mousedown') {
+      adbShell(shell_command);
+    }
+  }
 
   function startMouseTracking() {
     if (window.PointerEvent) {
diff --git a/host/libs/allocd/alloc_utils.cpp b/host/libs/allocd/alloc_utils.cpp
index 883b42c..8c91686 100644
--- a/host/libs/allocd/alloc_utils.cpp
+++ b/host/libs/allocd/alloc_utils.cpp
@@ -63,17 +63,13 @@
   return status == 0;
 }
 
-bool CreateWirelessIface(const std::string& name, bool has_ipv4_bridge,
-                         bool has_ipv6_bridge, bool use_ebtables_legacy) {
+bool CreateEthernetIface(const std::string& name, const std::string& bridge_name,
+                         bool has_ipv4_bridge, bool has_ipv6_bridge,
+                         bool use_ebtables_legacy) {
   // assume bridge exists
 
-  WirelessNetworkConfig config{false, false, false};
+  EthernetNetworkConfig config{false, false, false};
 
-  // TODO (paulkirth): change this to cvd-wbr, to test w/ today's debian
-  // package, this is required since the number of wireless bridges provided by
-  // the debian package has gone from 10 down to 1, but our debian packages in
-  // cloudtop are not up to date
-  auto bridge_name = "cvd-wbr-01";
   if (!CreateTap(name)) {
     return false;
   }
@@ -81,13 +77,13 @@
   config.has_tap = true;
 
   if (!LinkTapToBridge(name, bridge_name)) {
-    CleanupWirelessIface(name, config);
+    CleanupEthernetIface(name, config);
     return false;
   }
 
   if (!has_ipv4_bridge) {
     if (!CreateEbtables(name, true, use_ebtables_legacy)) {
-      CleanupWirelessIface(name, config);
+      CleanupEthernetIface(name, config);
       return false;
     }
     config.has_broute_ipv4 = true;
@@ -95,7 +91,7 @@
 
   if (!has_ipv6_bridge) {
     if (CreateEbtables(name, false, use_ebtables_legacy)) {
-      CleanupWirelessIface(name, config);
+      CleanupEthernetIface(name, config);
       return false;
     }
     config.has_broute_ipv6 = true;
@@ -183,7 +179,7 @@
   return status == 0;
 }
 
-bool DestroyWirelessIface(const std::string& name, bool has_ipv4_bridge,
+bool DestroyEthernetIface(const std::string& name, bool has_ipv4_bridge,
                           bool has_ipv6_bridge, bool use_ebtables_legacy) {
   if (!has_ipv6_bridge) {
     DestroyEbtables(name, false, use_ebtables_legacy);
@@ -196,8 +192,8 @@
   return DestroyIface(name);
 }
 
-void CleanupWirelessIface(const std::string& name,
-                          const WirelessNetworkConfig& config) {
+void CleanupEthernetIface(const std::string& name,
+                          const EthernetNetworkConfig& config) {
   if (config.has_broute_ipv6) {
     DestroyEbtables(name, false, config.use_ebtables_legacy);
   }
@@ -458,12 +454,13 @@
   return status == 0;
 }
 
-bool CreateWirelessBridgeIface(const std::string& name) {
+bool CreateEthernetBridgeIface(const std::string& name,
+                               const std::string& ipaddr) {
   if (!CreateBridge(name)) {
     return false;
   }
 
-  if (!SetupBridgeGateway(name, kWirelessIp)) {
+  if (!SetupBridgeGateway(name, ipaddr)) {
     DestroyBridge(name);
     return false;
   }
@@ -471,12 +468,13 @@
   return true;
 }
 
-bool DestroyWirelessBridgeIface(const std::string& name) {
+bool DestroyEthernetBridgeIface(const std::string& name,
+                                const std::string& ipaddr) {
   GatewayConfig config{true, true, true};
 
   // Don't need to check if removing some part of the config failed, we need to
   // remove the entire interface, so just ignore any error until the end
-  CleanupBridgeGateway(name, kWirelessIp, config);
+  CleanupBridgeGateway(name, ipaddr, config);
 
   return DestroyBridge(name);
 }
diff --git a/host/libs/allocd/alloc_utils.h b/host/libs/allocd/alloc_utils.h
index 5c1fd76..63f6d26 100644
--- a/host/libs/allocd/alloc_utils.h
+++ b/host/libs/allocd/alloc_utils.h
@@ -36,6 +36,8 @@
 constexpr char kWirelessIp[] = "192.168.96";
 // Mobile network prefix
 constexpr char kMobileIp[] = "192.168.97";
+// Ethernet network prefix
+constexpr char kEthernetIp[] = "192.168.98";
 // permission bits for socket
 constexpr int kSocketMode = 0666;
 
@@ -46,7 +48,7 @@
 constexpr uint32_t kMaxIfaceNameId = 63;
 
 // struct for managing configuration state
-struct WirelessNetworkConfig {
+struct EthernetNetworkConfig {
   bool has_broute_ipv4 = false;
   bool has_broute_ipv6 = false;
   bool has_tap = false;
@@ -89,12 +91,14 @@
 bool DestroyMobileIface(const std::string& name, uint16_t id,
                         const std::string& ipaddr);
 
-bool CreateWirelessIface(const std::string& name, bool has_ipv4_bridge,
-                         bool has_ipv6_bridge, bool use_ebtables_legacy);
-bool DestroyWirelessIface(const std::string& name, bool has_ipv4_bridge,
-                          bool use_ipv6, bool use_ebtables_legacy);
-void CleanupWirelessIface(const std::string& name,
-                          const WirelessNetworkConfig& config);
+bool CreateEthernetIface(const std::string& name, const std::string& bridge_name,
+                         bool has_ipv4_bridge, bool has_ipv6_bridge,
+                         bool use_ebtables_legacy);
+bool DestroyEthernetIface(const std::string& name,
+                          bool has_ipv4_bridge, bool use_ipv6,
+                          bool use_ebtables_legacy);
+void CleanupEthernetIface(const std::string& name,
+                          const EthernetNetworkConfig& config);
 
 bool IptableConfig(const std::string& network, bool add);
 
@@ -105,8 +109,10 @@
 void CleanupBridgeGateway(const std::string& name, const std::string& ipaddr,
                           const GatewayConfig& config);
 
-bool CreateWirelessBridgeIface(const std::string& name);
-bool DestroyWirelessBridgeIface(const std::string& name);
+bool CreateEthernetBridgeIface(const std::string& name,
+                               const std::string &ipaddr);
+bool DestroyEthernetBridgeIface(const std::string& name,
+                                const std::string &ipaddr);
 
 bool AddGateway(const std::string& name, const std::string& gateway,
                 const std::string& netmask);
diff --git a/host/libs/allocd/request.h b/host/libs/allocd/request.h
index 8e1302d..1fbf6c9 100644
--- a/host/libs/allocd/request.h
+++ b/host/libs/allocd/request.h
@@ -45,7 +45,9 @@
   Invalid = 0,  // an invalid interface
   mtap,         // mobile tap
   wtap,         // wireless tap
-  wbr           // wireless bridge
+  etap,         // ethernet tap
+  wbr,          // wireless bridge
+  ebr           // ethernet bridge
 };
 
 enum class RequestStatus : uint16_t {
diff --git a/host/libs/allocd/resource.cpp b/host/libs/allocd/resource.cpp
index 5a8b475..881dd85 100644
--- a/host/libs/allocd/resource.cpp
+++ b/host/libs/allocd/resource.cpp
@@ -30,13 +30,13 @@
   return DestroyMobileIface(GetName(), iface_id_, ipaddr_);
 }
 
-bool WirelessIface::AcquireResource() {
-  return CreateWirelessIface(GetName(), has_ipv4_, has_ipv6_,
+bool EthernetIface::AcquireResource() {
+  return CreateEthernetIface(GetName(), GetBridgeName(), has_ipv4_, has_ipv6_,
                              use_ebtables_legacy_);
 }
 
-bool WirelessIface::ReleaseResource() {
-  return DestroyWirelessIface(GetName(), has_ipv4_, has_ipv6_,
+bool EthernetIface::ReleaseResource() {
+  return DestroyEthernetIface(GetName(), has_ipv4_, has_ipv6_,
                               use_ebtables_legacy_);
 }
 
diff --git a/host/libs/allocd/resource.h b/host/libs/allocd/resource.h
index a92d38b..c30e9cc 100644
--- a/host/libs/allocd/resource.h
+++ b/host/libs/allocd/resource.h
@@ -26,8 +26,8 @@
 enum class ResourceType {
   Invalid = 0,
   MobileIface,
-  WirelessIface,
-  WirelessBridge
+  EthernetIface,
+  EthernetBridge,
 };
 
 class StaticResource {
@@ -75,21 +75,25 @@
   std::string ipaddr_;
 };
 
-class WirelessIface : public StaticResource {
+class EthernetIface : public StaticResource {
  public:
-  WirelessIface() = default;
-  ~WirelessIface() = default;
+  EthernetIface() = default;
+  ~EthernetIface() = default;
 
-  WirelessIface(const std::string& name, uid_t uid, uint16_t iface_id,
-                uint32_t global_id, std::string ipaddr)
+  EthernetIface(const std::string& name, uid_t uid, uint16_t iface_id,
+                uint32_t global_id, std::string bridge_name,
+                std::string ipaddr)
       : StaticResource(name, uid, ResourceType::MobileIface, global_id),
         iface_id_(iface_id),
+        bridge_name_(bridge_name),
         ipaddr_(ipaddr) {}
 
   bool ReleaseResource() override;
   bool AcquireResource() override;
 
   uint16_t GetIfaceId() { return iface_id_; }
+
+  std::string GetBridgeName() { return bridge_name_; }
   std::string GetIpAddr() { return ipaddr_; }
 
   void SetHasIpv4(bool ipv4) { has_ipv4_ = ipv4; }
@@ -105,6 +109,7 @@
  private:
   static constexpr char kNetmask[] = "/24";
   uint16_t iface_id_;
+  std::string bridge_name_;
   std::string ipaddr_;
   bool has_ipv4_ = true;
   bool has_ipv6_ = true;
diff --git a/host/libs/allocd/resource_manager.cpp b/host/libs/allocd/resource_manager.cpp
index 1ec6ac1..aaa8fe0 100644
--- a/host/libs/allocd/resource_manager.cpp
+++ b/host/libs/allocd/resource_manager.cpp
@@ -85,16 +85,20 @@
     const char* idp = iface.c_str() + (iface.size() - 3);
     int small_id = atoi(idp);
     switch (ty) {
-      case IfaceType::mtap: {
+      case IfaceType::mtap:
         res = std::make_shared<MobileIface>(iface, uid, small_id, resource_id,
                                             kMobileIp);
         allocatedIface = res->AcquireResource();
         pending_add_.insert({resource_id, res});
         break;
-      }
       case IfaceType::wtap: {
-        auto w = std::make_shared<WirelessIface>(iface, uid, small_id,
-                                                 resource_id, kMobileIp);
+        // TODO (paulkirth): change this to cvd-wbr, to test w/ today's
+        // debian package, this is required since the number of wireless
+        // bridges provided by the debian package has gone from 10 down to
+        // 1, but our debian packages in cloudtop are not up to date
+        auto w = std::make_shared<EthernetIface>(iface, uid, small_id,
+                                                 resource_id, "cvd-wbr-01",
+                                                 kWirelessIp);
         w->SetUseEbtablesLegacy(use_ebtables_legacy_);
         w->SetHasIpv4(use_ipv4_bridge_);
         w->SetHasIpv6(use_ipv6_bridge_);
@@ -103,10 +107,22 @@
         pending_add_.insert({resource_id, res});
         break;
       }
-      case IfaceType::wbr: {
-        allocatedIface = CreateBridge(iface);
+      case IfaceType::etap: {
+        auto w = std::make_shared<EthernetIface>(iface, uid, small_id,
+                                                 resource_id, "cvd-ebr",
+                                                 kEthernetIp);
+        w->SetUseEbtablesLegacy(use_ebtables_legacy_);
+        w->SetHasIpv4(use_ipv4_bridge_);
+        w->SetHasIpv6(use_ipv6_bridge_);
+        res = w;
+        allocatedIface = res->AcquireResource();
+        pending_add_.insert({resource_id, res});
         break;
       }
+      case IfaceType::wbr:
+      case IfaceType::ebr:
+        allocatedIface = CreateBridge(iface);
+        break;
       case IfaceType::Invalid:
         break;
     }
@@ -138,15 +154,15 @@
         removedIface = DestroyMobileIface(iface, id, kMobileIp);
         break;
       }
-      case IfaceType::wtap: {
-        removedIface = DestroyWirelessIface(
+      case IfaceType::wtap:
+      case IfaceType::etap:
+        removedIface = DestroyEthernetIface(
             iface, use_ipv4_bridge_, use_ipv6_bridge_, use_ebtables_legacy_);
         break;
-      }
-      case IfaceType::wbr: {
+      case IfaceType::wbr:
+      case IfaceType::ebr:
         removedIface = DestroyBridge(iface);
         break;
-      }
       case IfaceType::Invalid:
         break;
     }
diff --git a/host/libs/allocd/utils.cpp b/host/libs/allocd/utils.cpp
index 603c8d0..c60dc0c 100644
--- a/host/libs/allocd/utils.cpp
+++ b/host/libs/allocd/utils.cpp
@@ -58,13 +58,17 @@
     {"invalid", IfaceType::Invalid},
     {"mtap", IfaceType::mtap},
     {"wtap", IfaceType::wtap},
-    {"wbr", IfaceType::wbr}};
+    {"etap", IfaceType::etap},
+    {"wbr", IfaceType::wbr},
+    {"ebr", IfaceType::ebr}};
 
 const std::map<IfaceType, std::string> IfaceTyToStrMap = {
     {IfaceType::Invalid, "invalid"},
     {IfaceType::mtap, "mtap"},
     {IfaceType::wtap, "wtap"},
-    {IfaceType::wbr, "wbr"}};
+    {IfaceType::etap, "etap"},
+    {IfaceType::wbr, "wbr"},
+    {IfaceType::ebr, "ebr"}};
 
 const std::map<RequestStatus, std::string> ReqStatusToStrMap = {
     {RequestStatus::Invalid, "invalid"},
@@ -167,8 +171,12 @@
       return "mtap";
     case IfaceType::wtap:
       return "wtap";
+    case IfaceType::etap:
+      return "etap";
     case IfaceType::wbr:
       return "wbr";
+    case IfaceType::ebr:
+      return "ebr";
   }
 }
 
diff --git a/host/libs/config/Android.bp b/host/libs/config/Android.bp
index 53db489..152a0de 100644
--- a/host/libs/config/Android.bp
+++ b/host/libs/config/Android.bp
@@ -16,6 +16,7 @@
 cc_library_static {
     name: "libcuttlefish_host_config",
     srcs: [
+        "custom_actions.cpp",
         "cuttlefish_config.cpp",
         "cuttlefish_config_instance.cpp",
         "data_image.cpp",
diff --git a/host/libs/config/custom_actions.cpp b/host/libs/config/custom_actions.cpp
new file mode 100644
index 0000000..2c78eba
--- /dev/null
+++ b/host/libs/config/custom_actions.cpp
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+#include "host/libs/config/custom_actions.h"
+
+#include <android-base/logging.h>
+#include <json/json.h>
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "host/libs/config/cuttlefish_config.h"
+
+namespace cuttlefish {
+namespace {
+
+const char* kCustomActionShellCommand = "shell_command";
+const char* kCustomActionServer = "server";
+const char* kCustomActionButton = "button";
+const char* kCustomActionButtons = "buttons";
+const char* kCustomActionButtonCommand = "command";
+const char* kCustomActionButtonTitle = "title";
+const char* kCustomActionButtonIconName = "icon_name";
+
+} //namespace
+
+
+CustomActionConfig::CustomActionConfig(const Json::Value& dictionary) {
+  if (dictionary.isMember(kCustomActionShellCommand)) {
+    if (dictionary.isMember(kCustomActionServer)) {
+      LOG(ERROR) << "Custom action contains both shell command and action server.";
+      return;
+    }
+    // Shell command with one button.
+    Json::Value button_entry = dictionary[kCustomActionButton];
+    buttons = {{button_entry[kCustomActionButtonCommand].asString(),
+                button_entry[kCustomActionButtonTitle].asString(),
+                button_entry[kCustomActionButtonIconName].asString()}};
+    shell_command = dictionary[kCustomActionShellCommand].asString();
+  } else if (dictionary.isMember(kCustomActionServer)) {
+    // Action server with possibly multiple buttons.
+    for (const Json::Value& button_entry : dictionary[kCustomActionButtons]) {
+      ControlPanelButton button = {
+          button_entry[kCustomActionButtonCommand].asString(),
+          button_entry[kCustomActionButtonTitle].asString(),
+          button_entry[kCustomActionButtonIconName].asString()};
+      buttons.push_back(button);
+    }
+    server = dictionary[kCustomActionServer].asString();
+  } else {
+    LOG(ERROR) << "Unknown custom action format.";
+  }
+}
+
+Json::Value CustomActionConfig::ToJson() const {
+  Json::Value custom_action;
+  if (shell_command) {
+    // Shell command with one button.
+    custom_action[kCustomActionShellCommand] = *shell_command;
+    custom_action[kCustomActionButton] = Json::Value();
+    custom_action[kCustomActionButton][kCustomActionButtonCommand] =
+        buttons[0].command;
+    custom_action[kCustomActionButton][kCustomActionButtonTitle] =
+        buttons[0].title;
+    custom_action[kCustomActionButton][kCustomActionButtonIconName] =
+        buttons[0].icon_name;
+  } else if (server) {
+    // Action server with possibly multiple buttons.
+    custom_action[kCustomActionServer] = *server;
+    custom_action[kCustomActionButtons] = Json::Value(Json::arrayValue);
+    for (const auto& button : buttons) {
+      Json::Value button_entry;
+      button_entry[kCustomActionButtonCommand] = button.command;
+      button_entry[kCustomActionButtonTitle] = button.title;
+      button_entry[kCustomActionButtonIconName] = button.icon_name;
+      custom_action[kCustomActionButtons].append(button_entry);
+    }
+  } else {
+    LOG(ERROR) << "Unknown custom action type.";
+  }
+  return custom_action;
+}
+
+}  // namespace cuttlefish
diff --git a/host/libs/config/custom_actions.h b/host/libs/config/custom_actions.h
new file mode 100644
index 0000000..51e73ba
--- /dev/null
+++ b/host/libs/config/custom_actions.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+#pragma once
+
+#include <json/json.h>
+
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace cuttlefish {
+
+struct ControlPanelButton {
+  std::string command;
+  std::string title;
+  std::string icon_name;
+};
+
+struct CustomActionConfig {
+  CustomActionConfig(const Json::Value&);
+  Json::Value ToJson() const;
+
+  std::vector<ControlPanelButton> buttons;
+  std::optional<std::string> shell_command;
+  std::optional<std::string> server;
+};
+
+}  // namespace cuttlefish
diff --git a/host/libs/config/cuttlefish_config.cpp b/host/libs/config/cuttlefish_config.cpp
index 8d9a645..1a50ff6 100644
--- a/host/libs/config/cuttlefish_config.cpp
+++ b/host/libs/config/cuttlefish_config.cpp
@@ -117,6 +117,8 @@
 const char* kEnableVehicleHalServer = "enable_vehicle_hal_server";
 const char* kVehicleHalServerBinary = "vehicle_hal_server_binary";
 
+const char* kCustomActions = "custom_actions";
+
 const char* kRestartSubprocesses = "restart_subprocesses";
 const char* kRunAdbConnector = "run_adb_connector";
 
@@ -166,6 +168,8 @@
 
 const char* kVhostNet = "vhost_net";
 
+const char* kEthernet = "ethernet";
+
 }  // namespace
 
 const char* const kGpuModeAuto = "auto";
@@ -448,6 +452,22 @@
   return (*dictionary_)[kVehicleHalServerBinary].asString();
 }
 
+void CuttlefishConfig::set_custom_actions(const std::vector<CustomActionConfig>& actions) {
+  Json::Value actions_array(Json::arrayValue);
+  for (const auto& action : actions) {
+    actions_array.append(action.ToJson());
+  }
+  (*dictionary_)[kCustomActions] = actions_array;
+}
+
+std::vector<CustomActionConfig> CuttlefishConfig::custom_actions() const {
+  std::vector<CustomActionConfig> result;
+  for (Json::Value custom_action : (*dictionary_)[kCustomActions]) {
+    result.push_back(CustomActionConfig(custom_action));
+  }
+  return result;
+}
+
 void CuttlefishConfig::set_webrtc_assets_dir(const std::string& webrtc_assets_dir) {
   (*dictionary_)[kWebRTCAssetsDir] = webrtc_assets_dir;
 }
@@ -775,6 +795,13 @@
   return (*dictionary_)[kVhostNet].asBool();
 }
 
+void CuttlefishConfig::set_ethernet(bool ethernet) {
+  (*dictionary_)[kEthernet] = ethernet;
+}
+bool CuttlefishConfig::ethernet() const {
+  return (*dictionary_)[kEthernet].asBool();
+}
+
 // Creates the (initially empty) config object and populates it with values from
 // the config file if the CUTTLEFISH_CONFIG_FILE env variable is present.
 // Returns nullptr if there was an error loading from file
diff --git a/host/libs/config/cuttlefish_config.h b/host/libs/config/cuttlefish_config.h
index bccc3ab..a199728 100644
--- a/host/libs/config/cuttlefish_config.h
+++ b/host/libs/config/cuttlefish_config.h
@@ -20,10 +20,13 @@
 #include <cstdint>
 #include <map>
 #include <memory>
+#include <optional>
 #include <string>
 #include <set>
 #include <vector>
 
+#include "host/libs/config/custom_actions.h"
+
 namespace Json {
 class Value;
 }
@@ -45,6 +48,8 @@
     "VIRTUAL_DEVICE_NETWORK_MOBILE_CONNECTED";
 constexpr char kWifiConnectedMessage[] =
     "VIRTUAL_DEVICE_NETWORK_WIFI_CONNECTED";
+constexpr char kEthernetConnectedMessage[] =
+    "VIRTUAL_DEVICE_NETWORK_ETHERNET_CONNECTED";
 constexpr char kScreenChangedMessage[] = "VIRTUAL_DEVICE_SCREEN_CHANGED";
 constexpr char kInternalDirName[] = "internal";
 constexpr char kSharedDirName[] = "shared";
@@ -189,6 +194,9 @@
   void set_vehicle_hal_grpc_server_binary(const std::string& vhal_server_binary);
   std::string vehicle_hal_grpc_server_binary() const;
 
+  void set_custom_actions(const std::vector<CustomActionConfig>& actions);
+  std::vector<CustomActionConfig> custom_actions() const;
+
   void set_restart_subprocesses(bool restart_subprocesses);
   bool restart_subprocesses() const;
 
@@ -313,6 +321,9 @@
   void set_vhost_net(bool vhost_net);
   bool vhost_net() const;
 
+  void set_ethernet(bool ethernet);
+  bool ethernet() const;
+
   class InstanceSpecific;
   class MutableInstanceSpecific;
 
@@ -369,6 +380,7 @@
     std::string mobile_bridge_name() const;
     std::string mobile_tap_name() const;
     std::string wifi_tap_name() const;
+    std::string ethernet_tap_name() const;
     uint32_t session_id() const;
     bool use_allocd() const;
     int vsock_guest_cid() const;
@@ -461,6 +473,7 @@
     void set_mobile_bridge_name(const std::string& mobile_bridge_name);
     void set_mobile_tap_name(const std::string& mobile_tap_name);
     void set_wifi_tap_name(const std::string& wifi_tap_name);
+    void set_ethernet_tap_name(const std::string& ethernet_tap_name);
     void set_session_id(uint32_t session_id);
     void set_use_allocd(bool use_allocd);
     void set_vsock_guest_cid(int vsock_guest_cid);
diff --git a/host/libs/config/cuttlefish_config_instance.cpp b/host/libs/config/cuttlefish_config_instance.cpp
index 8bb4c84..d0f919e 100644
--- a/host/libs/config/cuttlefish_config_instance.cpp
+++ b/host/libs/config/cuttlefish_config_instance.cpp
@@ -34,6 +34,7 @@
 const char* kMobileBridgeName = "mobile_bridge_name";
 const char* kMobileTapName = "mobile_tap_name";
 const char* kWifiTapName = "wifi_tap_name";
+const char* kEthernetTapName = "ethernet_tap_name";
 const char* kVsockGuestCid = "vsock_guest_cid";
 
 const char* kSessionId = "session_id";
@@ -220,6 +221,14 @@
   (*Dictionary())[kWifiTapName] = wifi_tap_name;
 }
 
+std::string CuttlefishConfig::InstanceSpecific::ethernet_tap_name() const {
+  return (*Dictionary())[kEthernetTapName].asString();
+}
+void CuttlefishConfig::MutableInstanceSpecific::set_ethernet_tap_name(
+    const std::string& ethernet_tap_name) {
+  (*Dictionary())[kEthernetTapName] = ethernet_tap_name;
+}
+
 bool CuttlefishConfig::InstanceSpecific::use_allocd() const {
   return (*Dictionary())[kUseAllocd].asBool();
 }
diff --git a/host/libs/screen_connector/screen_connector.cpp b/host/libs/screen_connector/screen_connector.cpp
index 91bd0c4..a7d28ca 100644
--- a/host/libs/screen_connector/screen_connector.cpp
+++ b/host/libs/screen_connector/screen_connector.cpp
@@ -37,4 +37,7 @@
   }
 }
 
+// Ignore by default
+void ScreenConnector::ReportClientsConnected(bool /*have_clients*/) {}
+
 }  // namespace cuttlefish
diff --git a/host/libs/screen_connector/screen_connector.h b/host/libs/screen_connector/screen_connector.h
index 68df5eb..2768cca 100644
--- a/host/libs/screen_connector/screen_connector.h
+++ b/host/libs/screen_connector/screen_connector.h
@@ -38,6 +38,9 @@
   virtual bool OnFrameAfter(std::uint32_t frame_number,
                             const FrameCallback& frame_callback) = 0;
 
+  // Let the screen connector know when there are clients connected
+  virtual void ReportClientsConnected(bool have_clients);
+
   static inline constexpr int BytesPerPixel() {
       return sizeof(int32_t);
   }
@@ -62,4 +65,4 @@
   ScreenConnector() = default;
 };
 
-}  // namespace cuttlefish
\ No newline at end of file
+}  // namespace cuttlefish
diff --git a/host/libs/screen_connector/socket_based_screen_connector.cpp b/host/libs/screen_connector/socket_based_screen_connector.cpp
index d7d2168..a0498b5 100644
--- a/host/libs/screen_connector/socket_based_screen_connector.cpp
+++ b/host/libs/screen_connector/socket_based_screen_connector.cpp
@@ -66,23 +66,24 @@
 
   while (1) {
     LOG(DEBUG) << "Screen Connector accepting connections...";
-    auto conn = SharedFD::Accept(*server);
-    if (!conn->IsOpen()) {
+    client_connection_ = SharedFD::Accept(*server);
+    if (!client_connection_->IsOpen()) {
       LOG(ERROR) << "Disconnected fd returned from accept";
       continue;
     }
-    while (conn->IsOpen()) {
+    ReportClientsConnected(have_clients_);
+    while (client_connection_->IsOpen()) {
       int32_t size = 0;
-      if (conn->Read(&size, sizeof(size)) < 0) {
-        LOG(ERROR) << "Failed to read from hwcomposer: " << conn->StrError();
+      if (client_connection_->Read(&size, sizeof(size)) < 0) {
+        LOG(ERROR) << "Failed to read from hwcomposer: " << client_connection_->StrError();
         break;
       }
       auto buff = reinterpret_cast<uint8_t*>(GetBuffer(current_buffer));
       while (size > 0) {
-        auto read = conn->Read(buff, size);
+        auto read = client_connection_->Read(buff, size);
         if (read < 0) {
-          LOG(ERROR) << "Failed to read from hwcomposer: " << conn->StrError();
-          conn->Close();
+          LOG(ERROR) << "Failed to read from hwcomposer: " << client_connection_->StrError();
+          client_connection_->Close();
           break;
         }
         size -= read;
@@ -94,6 +95,12 @@
   }
 }
 
+void SocketBasedScreenConnector::ReportClientsConnected(bool have_clients) {
+  have_clients_ = have_clients;
+  char buffer = have_clients ? 1 : 0;
+  (void)client_connection_->Write(&buffer, sizeof(buffer));
+}
+
 void SocketBasedScreenConnector::BroadcastNewFrame(int buffer_idx) {
   {
     std::lock_guard<std::mutex> lock(new_frame_mtx_);
diff --git a/host/libs/screen_connector/socket_based_screen_connector.h b/host/libs/screen_connector/socket_based_screen_connector.h
index 1163e85..b43eabf 100644
--- a/host/libs/screen_connector/socket_based_screen_connector.h
+++ b/host/libs/screen_connector/socket_based_screen_connector.h
@@ -25,6 +25,8 @@
 #include <thread>
 #include <vector>
 
+#include "common/libs/fs/shared_fd.h"
+
 namespace cuttlefish {
 
 class SocketBasedScreenConnector : public ScreenConnector {
@@ -34,6 +36,8 @@
   bool OnFrameAfter(std::uint32_t frame_number,
                     const FrameCallback& frame_callback) override;
 
+  void ReportClientsConnected(bool have_clients) override;
+
  private:
   static constexpr int NUM_BUFFERS_ = 4;
 
@@ -49,6 +53,8 @@
   std::condition_variable new_frame_cond_var_;
   std::mutex new_frame_mtx_;
   std::thread screen_server_thread_;
+  cuttlefish::SharedFD client_connection_;
+  bool have_clients_ = false;
 };
 
-} // namespace cuttlefish
\ No newline at end of file
+} // namespace cuttlefish
diff --git a/host/libs/vm_manager/crosvm_manager.cpp b/host/libs/vm_manager/crosvm_manager.cpp
index b067096..5d86aac 100644
--- a/host/libs/vm_manager/crosvm_manager.cpp
+++ b/host/libs/vm_manager/crosvm_manager.cpp
@@ -286,6 +286,12 @@
                           "path=", instance.PerInstanceInternalPath("gatekeeper_fifo_vm.out"),
                           ",input=", instance.PerInstanceInternalPath("gatekeeper_fifo_vm.in"));
 
+  // TODO(b/172286896): This is temporarily optional, but should be made
+  // unconditional and moved up to the other network devices area
+  if (config.ethernet()) {
+    AddTapFdParameter(&crosvm_cmd, instance.ethernet_tap_name());
+  }
+
   // TODO(b/162071003): virtiofs crashes without sandboxing, this should be fixed
   if (config.enable_sandbox()) {
     // Set up directory shared with virtiofs
diff --git a/host/libs/vm_manager/qemu_manager.cpp b/host/libs/vm_manager/qemu_manager.cpp
index fabe406..fd40bf3 100644
--- a/host/libs/vm_manager/qemu_manager.cpp
+++ b/host/libs/vm_manager/qemu_manager.cpp
@@ -411,6 +411,17 @@
   qemu_cmd.AddParameter("-device");
   qemu_cmd.AddParameter("AC97");
 
+  // TODO(b/172286896): This is temporarily optional, but should be made
+  // unconditional and moved up to the other network devices area
+  if (config.ethernet()) {
+    qemu_cmd.AddParameter("-netdev");
+    qemu_cmd.AddParameter("tap,id=hostnet2,ifname=", instance.ethernet_tap_name(),
+                          ",script=no,downscript=no", vhost_net);
+
+    qemu_cmd.AddParameter("-device");
+    qemu_cmd.AddParameter("virtio-net-pci-non-transitional,netdev=hostnet2,id=net2");
+  }
+
   if (config.use_bootloader()) {
     qemu_cmd.AddParameter("-bios");
     qemu_cmd.AddParameter(config.bootloader());
diff --git a/tests/recovery/Android.bp b/tests/recovery/Android.bp
new file mode 100644
index 0000000..05207ea
--- /dev/null
+++ b/tests/recovery/Android.bp
@@ -0,0 +1,26 @@
+// Copyright (C) 2020 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.
+
+java_test_host {
+    name: "RebootRecoveryTest",
+    srcs: [
+        "src/**/*.java",
+    ],
+    test_suites: [
+        "device-tests",
+    ],
+    libs: [
+        "tradefed",
+    ],
+}
diff --git a/tests/recovery/AndroidTest.xml b/tests/recovery/AndroidTest.xml
new file mode 100644
index 0000000..f21b659
--- /dev/null
+++ b/tests/recovery/AndroidTest.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+
+<configuration description="GKI Install test">
+    <test class="com.android.tradefed.testtype.HostTest" >
+        <option name="jar" value="RebootRecoveryTest.jar" />
+    </test>
+</configuration>
diff --git a/tests/recovery/src/com/android/cuttlefish/tests/RebootRecoveryTest.java b/tests/recovery/src/com/android/cuttlefish/tests/RebootRecoveryTest.java
new file mode 100644
index 0000000..f4d7757
--- /dev/null
+++ b/tests/recovery/src/com/android/cuttlefish/tests/RebootRecoveryTest.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2020 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.cuttlefish.tests;
+
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
+
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Test rebooting into recovery.
+ */
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class RebootRecoveryTest extends BaseHostJUnit4Test {
+    @Test
+    public void testRebootRecovery() throws Exception {
+        getDevice().rebootIntoRecovery();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        getDevice().reboot();
+    }
+}