[netstack3] Impl fuchsia.net.interfaces.admin

Implement basic Installer, DeviceControl, and Control interfaces. Enable
all the tests that we can. Notably missing observing link status, data
path, attach/detach, add/remove address. Sprinkled TODOs for missing
features.

Note1: This does not yet introduce `fuchsia.net.debug/Interfaces`
which should by itself enable quite a bit of test coverage.

Note2: Enable and Disable are implemented but not fully tested yet. The
existing tests require the link status to be observed by the stack. Only
very basic session attached on Enable signals are tested so far.

Bug: 48853
Bug: 88796
Bug: 88797

Change-Id: Iad7fb385ef4ad17978c86e0b6e9b2baa372f7978
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/665112
Fuchsia-Auto-Submit: Bruno Dal Bo <brunodalbo@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
Reviewed-by: Ghanan Gowripalan <ghanan@google.com>
Reviewed-by: Alex Konradi <akonradi@google.com>
Reviewed-by: Jeff Martin <martinjeffrey@google.com>
diff --git a/src/connectivity/lib/network-device/rust/src/client.rs b/src/connectivity/lib/network-device/rust/src/client.rs
index 50d6306..f1c55af 100644
--- a/src/connectivity/lib/network-device/rust/src/client.rs
+++ b/src/connectivity/lib/network-device/rust/src/client.rs
@@ -14,6 +14,7 @@
 use crate::error::{Error, Result};
 use crate::session::{Config, DeviceInfo, Port, Session, Task};
 
+#[derive(Clone)]
 /// A client that communicates with a network device to send and receive packets.
 pub struct Client {
     device: netdev::DeviceProxy,
@@ -25,6 +26,13 @@
         Client { device }
     }
 
+    /// Connects to the specified `port`.
+    pub fn connect_port(&self, port: Port) -> Result<netdev::PortProxy> {
+        let (port_proxy, port_server) = fidl::endpoints::create_proxy::<netdev::PortMarker>()?;
+        let () = self.device.get_port(&mut port.into(), port_server)?;
+        Ok(port_proxy)
+    }
+
     /// Retrieves information about the underlying network device.
     pub async fn device_info(&self) -> Result<DeviceInfo> {
         Ok(self.device.get_info().await?.try_into()?)
@@ -48,8 +56,7 @@
         port: Port,
         buffer: u32,
     ) -> Result<impl Stream<Item = Result<PortStatus>> + Unpin> {
-        let (port_proxy, port_server) = fidl::endpoints::create_proxy::<netdev::PortMarker>()?;
-        let () = self.device.get_port(&mut port.into(), port_server)?;
+        let port_proxy = self.connect_port(port)?;
         let (watcher_proxy, watcher_server) =
             fidl::endpoints::create_proxy::<netdev::StatusWatcherMarker>()?;
         let () = port_proxy.get_status_watcher(watcher_server, buffer)?;
@@ -99,6 +106,20 @@
     }
 }
 
+/// Network device information with all required fields.
+#[derive(Debug, Clone, ValidFidlTable)]
+#[fidl_table_src(netdev::PortInfo)]
+pub struct PortInfo {
+    /// Port's identifier.
+    pub id: Port,
+    /// Port's class.
+    pub class: netdev::DeviceClass,
+    /// Supported rx frame types on this port.
+    pub rx_types: Vec<netdev::FrameType>,
+    /// Supported tx frame types on this port.
+    pub tx_types: Vec<netdev::FrameTypeSupport>,
+}
+
 /// Dynamic port information with all required fields.
 #[derive(Debug, Clone, PartialEq, Eq, ValidFidlTable)]
 #[fidl_table_src(netdev::PortStatus)]
diff --git a/src/connectivity/lib/network-device/rust/src/lib.rs b/src/connectivity/lib/network-device/rust/src/lib.rs
index 246f74b..907274f 100644
--- a/src/connectivity/lib/network-device/rust/src/lib.rs
+++ b/src/connectivity/lib/network-device/rust/src/lib.rs
@@ -11,4 +11,4 @@
 
 pub use client::{Client, DevicePortEvent, PortStatus};
 pub use error::Error;
-pub use session::{Buffer, Config, DeviceInfo, Port, Session};
+pub use session::{Buffer, Config, DeviceInfo, Port, Session, Task};
diff --git a/src/connectivity/lib/network-device/rust/src/session/mod.rs b/src/connectivity/lib/network-device/rust/src/session/mod.rs
index 5167488..e45d1751 100644
--- a/src/connectivity/lib/network-device/rust/src/session/mod.rs
+++ b/src/connectivity/lib/network-device/rust/src/session/mod.rs
@@ -31,6 +31,7 @@
 };
 
 /// A session between network device client and driver.
+#[derive(Clone)]
 pub struct Session {
     inner: Weak<Inner>,
 }
@@ -76,18 +77,19 @@
     }
 
     /// Attaches [`Session`] to a port.
-    pub async fn attach<IntoIter, Iter>(&self, port: Port, rx_frames: IntoIter) -> Result<()>
+    pub async fn attach<IntoIter>(&self, port: Port, rx_frames: IntoIter) -> Result<()>
     where
-        IntoIter: IntoIterator<IntoIter = Iter>,
-        Iter: Iterator<Item = netdev::FrameType> + ExactSizeIterator,
+        IntoIter: IntoIterator<Item = netdev::FrameType>,
+        IntoIter::IntoIter: ExactSizeIterator,
     {
-        let mut iter = rx_frames.into_iter();
-        let () = self
-            .inner()?
-            .proxy
-            .attach(&mut port.into(), &mut iter)
-            .await?
-            .map_err(|raw| Error::Attach(port, zx::Status::from_raw(raw)))?;
+        // NB: Need to bind the future returned by `proxy.attach` to a variable
+        // otherwise this function's (`Session::attach`) returned future becomes
+        // not `Send` and we get unexpected compiler errors at a distance.
+        //
+        // The dyn borrow in the signature of `proxy.attach` seems to be the
+        // cause of the compiler's confusion.
+        let fut = self.inner()?.proxy.attach(&mut port.into(), &mut rx_frames.into_iter());
+        let () = fut.await?.map_err(|raw| Error::Attach(port, zx::Status::from_raw(raw)))?;
         Ok(())
     }
 
@@ -478,7 +480,7 @@
 }
 
 /// A port of the device.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub struct Port {
     base: u8,
     salt: u8,
diff --git a/src/connectivity/network/netstack3/BUILD.gn b/src/connectivity/network/netstack3/BUILD.gn
index 1a1be35..2503106 100644
--- a/src/connectivity/network/netstack3/BUILD.gn
+++ b/src/connectivity/network/netstack3/BUILD.gn
@@ -18,11 +18,13 @@
     "//sdk/fidl/fuchsia.net:fuchsia.net-rustc",
     "//sdk/fidl/fuchsia.net.debug:fuchsia.net.debug-rustc",
     "//sdk/fidl/fuchsia.net.interfaces:fuchsia.net.interfaces-rustc",
+    "//sdk/fidl/fuchsia.net.interfaces.admin:fuchsia.net.interfaces.admin-rustc",
     "//sdk/fidl/fuchsia.net.stack:fuchsia.net.stack-rustc",
     "//sdk/fidl/fuchsia.posix:fuchsia.posix-rustc",
     "//sdk/fidl/fuchsia.posix.socket:fuchsia.posix.socket-rustc",
     "//src/connectivity/lib/fidl_fuchsia_net_stack_ext",
     "//src/connectivity/lib/net-types",
+    "//src/connectivity/lib/network-device/rust",
     "//src/connectivity/lib/packet-formats",
     "//src/connectivity/network/lib/explicit",
     "//src/connectivity/network/lib/todo_unused",
@@ -63,9 +65,11 @@
     "src/bindings/devices.rs",
     "src/bindings/ethernet_worker.rs",
     "src/bindings/integration_tests.rs",
+    "src/bindings/interfaces_admin.rs",
     "src/bindings/interfaces_watcher.rs",
     "src/bindings/macros.rs",
     "src/bindings/mod.rs",
+    "src/bindings/netdevice_worker.rs",
     "src/bindings/socket/datagram.rs",
     "src/bindings/socket/mod.rs",
     "src/bindings/stack_fidl_worker.rs",
diff --git a/src/connectivity/network/netstack3/meta/netstack3.cml b/src/connectivity/network/netstack3/meta/netstack3.cml
index bfde093..2ea6e48 100644
--- a/src/connectivity/network/netstack3/meta/netstack3.cml
+++ b/src/connectivity/network/netstack3/meta/netstack3.cml
@@ -11,6 +11,7 @@
         {
             protocol: [
                 "fuchsia.net.debug.Interfaces",
+                "fuchsia.net.interfaces.admin.Installer",
                 "fuchsia.net.interfaces.State",
                 "fuchsia.net.stack.Stack",
                 "fuchsia.posix.socket.Provider",
@@ -21,6 +22,7 @@
         {
             protocol: [
                 "fuchsia.net.debug.Interfaces",
+                "fuchsia.net.interfaces.admin.Installer",
                 "fuchsia.net.interfaces.State",
                 "fuchsia.net.stack.Stack",
                 "fuchsia.posix.socket.Provider",
diff --git a/src/connectivity/network/netstack3/src/bindings/devices.rs b/src/connectivity/network/netstack3/src/bindings/devices.rs
index 7a8ddda..6cca28c 100644
--- a/src/connectivity/network/netstack3/src/bindings/devices.rs
+++ b/src/connectivity/network/netstack3/src/bindings/devices.rs
@@ -120,6 +120,7 @@
 #[derive(Debug)]
 pub enum DeviceSpecificInfo {
     Ethernet(EthernetInfo),
+    Netdevice(NetdeviceInfo),
     Loopback(LoopbackInfo),
 }
 
@@ -127,9 +128,18 @@
     pub fn common_info(&self) -> &CommonInfo {
         match self {
             Self::Ethernet(i) => &i.common_info,
+            Self::Netdevice(i) => &i.common_info,
             Self::Loopback(i) => &i.common_info,
         }
     }
+
+    pub fn common_info_mut(&mut self) -> &mut CommonInfo {
+        match self {
+            Self::Ethernet(i) => &mut i.common_info,
+            Self::Netdevice(i) => &mut i.common_info,
+            Self::Loopback(i) => &mut i.common_info,
+        }
+    }
 }
 
 /// Information common to all devices.
@@ -158,8 +168,23 @@
 }
 
 impl From<EthernetInfo> for DeviceSpecificInfo {
-    fn from(i: EthernetInfo) -> DeviceSpecificInfo {
-        DeviceSpecificInfo::Ethernet(i)
+    fn from(i: EthernetInfo) -> Self {
+        Self::Ethernet(i)
+    }
+}
+
+/// Network device information.
+#[derive(Debug)]
+pub struct NetdeviceInfo {
+    pub common_info: CommonInfo,
+    pub handler: super::netdevice_worker::PortHandler,
+    pub mac: UnicastAddr<Mac>,
+    pub phy_up: bool,
+}
+
+impl From<NetdeviceInfo> for DeviceSpecificInfo {
+    fn from(i: NetdeviceInfo) -> Self {
+        Self::Netdevice(i)
     }
 }
 
diff --git a/src/connectivity/network/netstack3/src/bindings/integration_tests.rs b/src/connectivity/network/netstack3/src/bindings/integration_tests.rs
index 3d7649a..6cff868 100644
--- a/src/connectivity/network/netstack3/src/bindings/integration_tests.rs
+++ b/src/connectivity/network/netstack3/src/bindings/integration_tests.rs
@@ -33,7 +33,10 @@
 
 use crate::bindings::{
     context::Lockable,
-    devices::{CommonInfo, DeviceInfo, DeviceSpecificInfo, Devices, EthernetInfo, LoopbackInfo},
+    devices::{
+        CommonInfo, DeviceInfo, DeviceSpecificInfo, Devices, EthernetInfo, LoopbackInfo,
+        NetdeviceInfo,
+    },
     socket::datagram::{IcmpEcho, SocketCollectionIpExt, Udp},
     util::{ConversionContext as _, IntoFidl as _, TryFromFidlWithContext as _, TryIntoFidl as _},
     BindingsContextImpl, BindingsDispatcher, DeviceStatusNotifier, LockableContext,
@@ -291,38 +294,35 @@
         Ok(stack)
     }
 
-    /// Waits for interface with given `if_id` to come online.
-    pub(crate) async fn wait_for_interface_online(&mut self, if_id: u64) {
-        let check_online = |info: &DeviceInfo| match info.info() {
+    fn is_interface_link_up(info: &DeviceInfo) -> bool {
+        match info.info() {
             DeviceSpecificInfo::Ethernet(EthernetInfo {
                 common_info: CommonInfo { admin_enabled: _, mtu: _, events: _, name: _ },
                 client: _,
                 mac: _,
                 features: _,
                 phy_up,
+            })
+            | DeviceSpecificInfo::Netdevice(NetdeviceInfo {
+                common_info: CommonInfo { admin_enabled: _, mtu: _, events: _, name: _ },
+                handler: _,
+                mac: _,
+                phy_up,
             }) => *phy_up,
             DeviceSpecificInfo::Loopback(LoopbackInfo {
                 common_info: CommonInfo { admin_enabled: _, mtu: _, events: _, name: _ },
             }) => true,
-        };
-        self.wait_for_interface_status(if_id, check_online).await;
+        }
+    }
+
+    /// Waits for interface with given `if_id` to come online.
+    pub(crate) async fn wait_for_interface_online(&mut self, if_id: u64) {
+        self.wait_for_interface_status(if_id, Self::is_interface_link_up).await;
     }
 
     /// Waits for interface with given `if_id` to go offline.
     pub(crate) async fn wait_for_interface_offline(&mut self, if_id: u64) {
-        let check_offline = |info: &DeviceInfo| match info.info() {
-            DeviceSpecificInfo::Ethernet(EthernetInfo {
-                common_info: CommonInfo { admin_enabled: _, mtu: _, events: _, name: _ },
-                client: _,
-                mac: _,
-                features: _,
-                phy_up,
-            }) => !phy_up,
-            DeviceSpecificInfo::Loopback(LoopbackInfo {
-                common_info: CommonInfo { admin_enabled: _, mtu: _, events: _, name: _ },
-            }) => false,
-        };
-        self.wait_for_interface_status(if_id, check_offline).await;
+        self.wait_for_interface_status(if_id, |info| !Self::is_interface_link_up(info)).await;
     }
 
     async fn wait_for_interface_status<F: Fn(&DeviceInfo) -> bool>(
diff --git a/src/connectivity/network/netstack3/src/bindings/interfaces_admin.rs b/src/connectivity/network/netstack3/src/bindings/interfaces_admin.rs
new file mode 100644
index 0000000..514e7db4
--- /dev/null
+++ b/src/connectivity/network/netstack3/src/bindings/interfaces_admin.rs
@@ -0,0 +1,363 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use fidl::endpoints::ProtocolMarker as _;
+use fidl_fuchsia_hardware_network as fhardware_network;
+use fidl_fuchsia_net_interfaces_admin as fnet_interfaces_admin;
+use fuchsia_async as fasync;
+use fuchsia_zircon as zx;
+
+use futures::{FutureExt as _, StreamExt as _, TryFutureExt as _, TryStreamExt as _};
+
+use crate::bindings::{
+    devices, netdevice_worker, BindingId, InterfaceControl as _, Netstack, NetstackContext,
+};
+
+pub(crate) fn serve(
+    ns: Netstack,
+    req: fnet_interfaces_admin::InstallerRequestStream,
+) -> impl futures::Stream<Item = Result<fasync::Task<()>, fidl::Error>> {
+    req.map_ok(
+        move |fnet_interfaces_admin::InstallerRequest::InstallDevice {
+                  device,
+                  device_control,
+                  control_handle: _,
+              }| {
+            fasync::Task::spawn(
+                run_device_control(
+                    ns.clone(),
+                    device,
+                    device_control.into_stream().expect("failed to obtain stream"),
+                )
+                .map(|r| r.unwrap_or_else(|e| log::warn!("device control finished with {:?}", e))),
+            )
+        },
+    )
+}
+
+#[derive(thiserror::Error, Debug)]
+enum DeviceControlError {
+    #[error("worker error: {0}")]
+    Worker(#[from] netdevice_worker::Error),
+    #[error("fidl error: {0}")]
+    Fidl(#[from] fidl::Error),
+}
+
+async fn run_device_control(
+    ns: Netstack,
+    device: fidl::endpoints::ClientEnd<fhardware_network::DeviceMarker>,
+    device_control: fnet_interfaces_admin::DeviceControlRequestStream,
+) -> Result<(), DeviceControlError> {
+    let worker = netdevice_worker::NetdeviceWorker::new(ns.ctx.clone(), device).await?;
+    let handler = worker.new_handler();
+    let worker_fut = worker.run().map_err(DeviceControlError::Worker);
+    let (stop_trigger, stop_fut) = futures::channel::oneshot::channel::<()>();
+    let stop_fut = stop_fut.map(|r| r.expect("closed all cancellation senders")).shared();
+    let control_stream = device_control
+        .take_until(stop_fut.clone())
+        .map_err(DeviceControlError::Fidl)
+        .try_filter_map(|req| match req {
+            fnet_interfaces_admin::DeviceControlRequest::CreateInterface {
+                port,
+                control,
+                options,
+                control_handle: _,
+            } => create_interface(port, control, options, &ns, &handler, &stop_fut),
+            fnet_interfaces_admin::DeviceControlRequest::Detach { control_handle: _ } => {
+                todo!("https://fxbug.dev/100867 support detach");
+            }
+        });
+    futures::pin_mut!(worker_fut);
+    futures::pin_mut!(control_stream);
+    let mut tasks = futures::stream::FuturesUnordered::new();
+    let res = loop {
+        let mut tasks_fut = if tasks.is_empty() {
+            futures::future::pending().left_future()
+        } else {
+            tasks.by_ref().next().right_future()
+        };
+        let result = futures::select! {
+            r = control_stream.try_next() => r,
+            r = worker_fut => match r {
+                Ok(never) => match never {},
+                Err(e) => Err(e)
+            },
+            ready_task = tasks_fut => {
+                let () = ready_task.unwrap_or_else(|| ());
+                continue;
+            }
+        };
+        match result {
+            Ok(Some(task)) => tasks.push(task),
+            Ok(None) => break Ok(()),
+            Err(e) => break Err(e),
+        }
+    };
+
+    // Send a stop signal to all tasks.
+    stop_trigger.send(()).expect("receiver should not be gone");
+    match &res {
+        // Control stream has finished, don't need to drain it.
+        Ok(()) | Err(DeviceControlError::Fidl(_)) => (),
+        Err(DeviceControlError::Worker(_)) => {
+            // Drain control stream to make sure we have all the tasks. The stop
+            // trigger will make it stop operating on new requests.
+            control_stream
+                .try_for_each(|t| futures::future::ok(tasks.push(t)))
+                .await
+                .unwrap_or_else(|e| log::warn!("failed to accumulate remaining tasks: {:?}", e));
+        }
+    }
+    // Run all the tasks to completion. We sent the stop signal, they should all
+    // complete and perform interface cleanup.
+    tasks.collect::<()>().await;
+
+    res
+}
+
+/// Operates a fuchsia.net.interfaces.admin/DeviceControl.CreateInterface
+/// request.
+///
+/// Returns `Ok(Some(fuchsia_async::Task))` if an interface was created
+/// successfully. The returned `Task` must be polled to completion and is tied
+/// to the created interface's lifetime.
+async fn create_interface(
+    port: fhardware_network::PortId,
+    control: fidl::endpoints::ServerEnd<fnet_interfaces_admin::ControlMarker>,
+    options: fnet_interfaces_admin::Options,
+    ns: &Netstack,
+    handler: &netdevice_worker::DeviceHandler,
+    stop_fut: &(impl futures::Future<Output = ()>
+          + futures::future::FusedFuture
+          + Clone
+          + Send
+          + 'static),
+) -> Result<Option<fuchsia_async::Task<()>>, DeviceControlError> {
+    log::debug!("creating interface from {:?} with {:?}", port, options);
+    let fnet_interfaces_admin::Options { name, metric: _, .. } = options;
+    match handler.add_port(&ns, netdevice_worker::InterfaceOptions { name }, port).await {
+        Ok(binding_id) => Ok(Some(fasync::Task::spawn(run_interface_control(
+            ns.ctx.clone(),
+            binding_id,
+            stop_fut.clone(),
+            control,
+        )))),
+        Err(e) => {
+            log::warn!("failed to add port {:?} to device: {:?}", port, e);
+            let removed_reason = match e {
+                netdevice_worker::Error::Client(e) => match e {
+                    // Assume any fidl errors are port closed
+                    // errors.
+                    netdevice_client::Error::Fidl(_) => {
+                        Some(fnet_interfaces_admin::InterfaceRemovedReason::PortClosed)
+                    }
+                    netdevice_client::Error::RxFlags(_)
+                    | netdevice_client::Error::FrameType(_)
+                    | netdevice_client::Error::NoProgress
+                    | netdevice_client::Error::PeerClosed(_)
+                    | netdevice_client::Error::Config(_)
+                    | netdevice_client::Error::LargeChain(_)
+                    | netdevice_client::Error::Index(_, _)
+                    | netdevice_client::Error::Pad(_, _)
+                    | netdevice_client::Error::TxLength(_, _)
+                    | netdevice_client::Error::Open(_, _)
+                    | netdevice_client::Error::Vmo(_, _)
+                    | netdevice_client::Error::Fifo(_, _, _)
+                    | netdevice_client::Error::VmoSize(_, _)
+                    | netdevice_client::Error::Map(_, _)
+                    | netdevice_client::Error::DeviceInfo(_)
+                    | netdevice_client::Error::PortStatus(_)
+                    | netdevice_client::Error::Attach(_, _)
+                    | netdevice_client::Error::Detach(_, _) => None,
+                },
+                netdevice_worker::Error::AlreadyInstalled(_) => {
+                    Some(fnet_interfaces_admin::InterfaceRemovedReason::PortAlreadyBound)
+                }
+                netdevice_worker::Error::CantConnectToPort(_) => {
+                    Some(fnet_interfaces_admin::InterfaceRemovedReason::PortClosed)
+                }
+                netdevice_worker::Error::ConfigurationNotSupported
+                | netdevice_worker::Error::MacNotUnicast { .. } => {
+                    Some(fnet_interfaces_admin::InterfaceRemovedReason::BadPort)
+                }
+                netdevice_worker::Error::SystemResource(_)
+                | netdevice_worker::Error::InvalidPortInfo(_)
+                | netdevice_worker::Error::InvalidPortStatus(_) => None,
+            };
+            if let Some(removed_reason) = removed_reason {
+                let (_stream, control) =
+                    control.into_stream_and_control_handle().expect("failed to acquire stream");
+                control.send_on_interface_removed(removed_reason).unwrap_or_else(|e| {
+                    log::warn!("failed to send removed reason: {:?}", e);
+                });
+            }
+            Ok(None)
+        }
+    }
+}
+
+async fn run_interface_control<
+    F: Send + 'static + futures::Future<Output = ()> + futures::future::FusedFuture,
+>(
+    ctx: NetstackContext,
+    id: BindingId,
+    cancel: F,
+    server_end: fidl::endpoints::ServerEnd<fnet_interfaces_admin::ControlMarker>,
+) {
+    let (mut stream, control_handle) =
+        server_end.into_stream_and_control_handle().expect("failed to create stream");
+    let stream_fut = async {
+        while let Some(req) = stream.try_next().await? {
+            log::debug!("serving {:?}", req);
+            let () = match req {
+                fnet_interfaces_admin::ControlRequest::AddAddress {
+                    address: _,
+                    parameters: _,
+                    address_state_provider: _,
+                    control_handle: _,
+                } => todo!("https://fxbug.dev/100870 support add address"),
+                fnet_interfaces_admin::ControlRequest::RemoveAddress {
+                    address: _,
+                    responder: _,
+                } => {
+                    todo!("https://fxbug.dev/100870 support remove address")
+                }
+                fnet_interfaces_admin::ControlRequest::GetId { responder } => responder.send(id),
+                fnet_interfaces_admin::ControlRequest::SetConfiguration {
+                    config: _,
+                    responder: _,
+                } => {
+                    todo!("https://fxbug.dev/76987 support enable/disable forwarding")
+                }
+                fnet_interfaces_admin::ControlRequest::GetConfiguration { responder: _ } => {
+                    todo!("https://fxbug.dev/76987 support enable/disable forwarding")
+                }
+                fnet_interfaces_admin::ControlRequest::Enable { responder } => {
+                    responder.send(&mut Ok(set_interface_enabled(&ctx, true, id).await))
+                }
+                fnet_interfaces_admin::ControlRequest::Disable { responder } => {
+                    responder.send(&mut Ok(set_interface_enabled(&ctx, false, id).await))
+                }
+                fnet_interfaces_admin::ControlRequest::Detach { control_handle: _ } => {
+                    todo!("https://fxbug.dev/100867 support detach");
+                }
+            }?;
+        }
+        Result::<_, fidl::Error>::Ok(())
+    }
+    .fuse();
+
+    enum Outcome {
+        Cancelled,
+        StreamEnded(Result<(), fidl::Error>),
+    }
+    futures::pin_mut!(stream_fut);
+    futures::pin_mut!(cancel);
+    let outcome = futures::select! {
+        o = stream_fut => Outcome::StreamEnded(o),
+        () = cancel => Outcome::Cancelled,
+    };
+    match outcome {
+        Outcome::Cancelled => {
+            // Device has been removed from under us, inform the user that's the
+            // case.
+            control_handle
+                .send_on_interface_removed(
+                    fnet_interfaces_admin::InterfaceRemovedReason::PortClosed,
+                )
+                .unwrap_or_else(|e| {
+                    if !e.is_closed() {
+                        log::error!("failed to send terminal event: {:?}", e)
+                    }
+                });
+        }
+        Outcome::StreamEnded(Err(e)) => {
+            log::error!(
+                "error operating {} stream: {:?}",
+                fnet_interfaces_admin::ControlMarker::DEBUG_NAME,
+                e
+            );
+        }
+        Outcome::StreamEnded(Ok(())) => (),
+    }
+
+    // Cleanup and remove the interface.
+
+    // TODO(https://fxbug.dev/88797): We're not supposed to cleanup if this is a
+    // debug channel.
+    // TODO(https://fxbug.dev/100867): We're not supposed to cleanup if we're
+    // detached.
+
+    let _: devices::DeviceInfo = ctx
+        .lock()
+        .await
+        .dispatcher
+        .devices
+        .remove_device(id)
+        .expect("device lifetime should be tied to channel lifetime");
+}
+
+/// Sets interface with `id` to `admin_enabled = enabled`.
+///
+/// Returns `true` if the value of `admin_enabled` changed in response to
+/// this call.
+async fn set_interface_enabled(ctx: &NetstackContext, enabled: bool, id: BindingId) -> bool {
+    let mut ctx = ctx.lock().await;
+    let (common_info, port_handler) =
+        match ctx.dispatcher.devices.get_device_mut(id).expect("device not present").info_mut() {
+            devices::DeviceSpecificInfo::Ethernet(devices::EthernetInfo {
+                common_info,
+                // NB: In theory we should also start and stop the ethernet
+                // device when we enable and disable, we'll skip that because
+                // it's work and Ethernet is going to be deleted soon.
+                client: _,
+                mac: _,
+                features: _,
+                phy_up: _,
+            })
+            | devices::DeviceSpecificInfo::Loopback(devices::LoopbackInfo { common_info }) => {
+                (common_info, None)
+            }
+            devices::DeviceSpecificInfo::Netdevice(devices::NetdeviceInfo {
+                common_info,
+                handler,
+                mac: _,
+                phy_up: _,
+            }) => (common_info, Some(handler)),
+        };
+    // Already set to expected value.
+    if common_info.admin_enabled == enabled {
+        return false;
+    }
+    common_info.admin_enabled = enabled;
+    if let Some(handler) = port_handler {
+        let r = if enabled { handler.attach().await } else { handler.detach().await };
+        match r {
+            Ok(()) => (),
+            Err(e) => {
+                log::warn!("failed to set port {:?} to {}: {:?}", handler, enabled, e);
+                // NB: There might be other errors here to consider in the
+                // future, we start with a very strict set of known errors to
+                // allow and panic on anything that is unexpected.
+                match e {
+                    // We can race with the port being removed or the device
+                    // being destroyed.
+                    netdevice_client::Error::Attach(_, zx::Status::NOT_FOUND)
+                    | netdevice_client::Error::Detach(_, zx::Status::NOT_FOUND) => (),
+                    netdevice_client::Error::Fidl(e) if e.is_closed() => (),
+                    e => panic!(
+                        "unexpected error setting enabled={} on port {:?}: {:?}",
+                        enabled, handler, e
+                    ),
+                }
+            }
+        }
+    }
+    if enabled {
+        ctx.enable_interface(id).expect("failed to enable interface");
+    } else {
+        ctx.disable_interface(id).expect("failed to disable interface");
+    }
+    true
+}
diff --git a/src/connectivity/network/netstack3/src/bindings/mod.rs b/src/connectivity/network/netstack3/src/bindings/mod.rs
index 2661f6a..2a64259 100644
--- a/src/connectivity/network/netstack3/src/bindings/mod.rs
+++ b/src/connectivity/network/netstack3/src/bindings/mod.rs
@@ -15,7 +15,9 @@
 mod context;
 mod devices;
 mod ethernet_worker;
+mod interfaces_admin;
 mod interfaces_watcher;
+mod netdevice_worker;
 mod socket;
 mod stack_fidl_worker;
 mod timers;
@@ -35,7 +37,9 @@
 use fuchsia_async as fasync;
 use fuchsia_component::server::{ServiceFs, ServiceFsDir};
 use fuchsia_zircon as zx;
-use futures::{lock::Mutex, FutureExt as _, StreamExt as _, TryStreamExt as _};
+use futures::{
+    channel::mpsc, lock::Mutex, FutureExt as _, SinkExt as _, StreamExt as _, TryStreamExt as _,
+};
 use log::{debug, error, warn};
 use packet::{BufferMut, Serializer};
 use packet_formats::icmp::{IcmpEchoReply, IcmpMessage, IcmpUnusedCode};
@@ -45,6 +49,7 @@
 use context::Lockable;
 use devices::{
     BindingId, CommonInfo, DeviceInfo, DeviceSpecificInfo, Devices, EthernetInfo, LoopbackInfo,
+    NetdeviceInfo,
 };
 use interfaces_watcher::{InterfaceEventProducer, InterfaceProperties, InterfaceUpdate};
 use timers::TimerDispatcher;
@@ -318,6 +323,18 @@
                     client.send(frame.as_ref())
                 }
             }
+            DeviceSpecificInfo::Netdevice(NetdeviceInfo {
+                common_info: CommonInfo { admin_enabled, mtu: _, events: _, name: _ },
+                handler,
+                mac: _,
+                phy_up,
+            }) => {
+                if *admin_enabled && *phy_up {
+                    handler.send(frame.as_ref()).unwrap_or_else(|e| {
+                        log::warn!("failed to send frame to {:?}: {:?}", handler, e)
+                    })
+                }
+            }
             DeviceSpecificInfo::Loopback(LoopbackInfo { .. }) => {
                 unreachable!("loopback must not send packets out of the node")
             }
@@ -527,6 +544,12 @@
             mac: _,
             features: _,
             phy_up,
+        })
+        | DeviceSpecificInfo::Netdevice(NetdeviceInfo {
+            common_info: CommonInfo { admin_enabled, mtu: _, events, name: _ },
+            handler: _,
+            mac: _,
+            phy_up,
         }) => (*admin_enabled && *phy_up, events),
         DeviceSpecificInfo::Loopback(LoopbackInfo {
             common_info: CommonInfo { admin_enabled, mtu: _, events, name: _ },
@@ -626,11 +649,13 @@
     Stack(fidl_fuchsia_net_stack::StackRequestStream),
     Socket(fidl_fuchsia_posix_socket::ProviderRequestStream),
     Interfaces(fidl_fuchsia_net_interfaces::StateRequestStream),
+    InterfacesAdmin(fidl_fuchsia_net_interfaces_admin::InstallerRequestStream),
     Debug(fidl_fuchsia_net_debug::InterfacesRequestStream),
 }
 
 enum WorkItem {
     Incoming(Service),
+    Task(fasync::Task<()>),
 }
 
 trait RequestStreamExt: RequestStream {
@@ -773,11 +798,20 @@
             .add_fidl_service(Service::Debug)
             .add_fidl_service(Service::Stack)
             .add_fidl_service(Service::Socket)
-            .add_fidl_service(Service::Interfaces);
+            .add_fidl_service(Service::Interfaces)
+            .add_fidl_service(Service::InterfacesAdmin);
 
         let services = fs.take_and_serve_directory_handle().context("directory handle")?;
-        let work_items = services.map(WorkItem::Incoming);
-        let service_fs_fut = work_items
+
+        // Buffer size doesn't matter much, we're just trying to reduce
+        // allocations.
+        const TASK_CHANNEL_BUFFER_SIZE: usize = 16;
+        let (task_sink, task_stream) = mpsc::channel(TASK_CHANNEL_BUFFER_SIZE);
+        let work_items = futures::stream::select(
+            services.map(WorkItem::Incoming),
+            task_stream.map(WorkItem::Task),
+        );
+        let work_items_fut = work_items
             .for_each_concurrent(None, |wi| async {
                 match wi {
                     WorkItem::Incoming(Service::Stack(stack)) => {
@@ -799,6 +833,23 @@
                             })
                             .await
                     }
+                    WorkItem::Incoming(Service::InterfacesAdmin(installer)) => {
+                        log::debug!(
+                            "serving {}",
+                            fidl_fuchsia_net_interfaces_admin::InstallerMarker::PROTOCOL_NAME
+                        );
+                        interfaces_admin::serve(netstack.clone(), installer)
+                            .map_err(anyhow::Error::from)
+                            .forward(task_sink.clone().sink_map_err(anyhow::Error::from))
+                            .await
+                            .unwrap_or_else(|e| {
+                                log::warn!(
+                                    "error serving {}: {:?}",
+                                    fidl_fuchsia_net_interfaces_admin::InstallerMarker::PROTOCOL_NAME,
+                                    e
+                                )
+                            })
+                    }
                     WorkItem::Incoming(Service::Debug(debug)) => {
                         // TODO(https://fxbug.dev/88797): Implement this
                         // properly. This protocol is stubbed out to allow
@@ -833,10 +884,11 @@
                             }))
                             .await
                     }
-                    }
+                    WorkItem::Task(task) => task.await
+                }
             });
 
-        let ((), ()) = futures::future::join(service_fs_fut, interfaces_worker_task).await;
+        let ((), ()) = futures::future::join(work_items_fut, interfaces_worker_task).await;
         debug!("Services stream finished");
         Ok(())
     }
diff --git a/src/connectivity/network/netstack3/src/bindings/netdevice_worker.rs b/src/connectivity/network/netstack3/src/bindings/netdevice_worker.rs
new file mode 100644
index 0000000..ec5363b
--- /dev/null
+++ b/src/connectivity/network/netstack3/src/bindings/netdevice_worker.rs
@@ -0,0 +1,244 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::{
+    collections::{hash_map::Entry, HashMap},
+    convert::TryInto as _,
+    sync::Arc,
+};
+
+use fidl_fuchsia_hardware_network as fhardware_network;
+use fidl_fuchsia_net as fnet;
+use fidl_fuchsia_net_interfaces as fnet_interfaces;
+
+use futures::{lock::Mutex, FutureExt as _};
+
+use crate::bindings::{
+    devices, BindingId, DeviceId, InterfaceEventProducerFactory as _, Netstack, NetstackContext,
+};
+
+#[derive(Clone)]
+struct Inner {
+    device: netdevice_client::Client,
+    session: netdevice_client::Session,
+    // TODO(https://fxbug.dev/101297): Replace hash map with a salted slab.
+    // `state` must be locked before any `NetstackContext` locks.
+    state: Arc<Mutex<HashMap<netdevice_client::Port, DeviceId>>>,
+}
+
+/// The worker that receives messages from the ethernet device, and passes them
+/// on to the main event loop.
+pub(crate) struct NetdeviceWorker {
+    ctx: NetstackContext,
+    task: netdevice_client::Task,
+    inner: Inner,
+}
+
+#[derive(thiserror::Error, Debug)]
+pub(crate) enum Error {
+    #[error("failed to create system resources: {0}")]
+    SystemResource(fidl::Error),
+    #[error("client error: {0}")]
+    Client(#[from] netdevice_client::Error),
+    #[error("port {0:?} already installed")]
+    AlreadyInstalled(netdevice_client::Port),
+    #[error("failed to connect to port: {0}")]
+    CantConnectToPort(fidl::Error),
+    #[error("invalid port info: {0}")]
+    InvalidPortInfo(netdevice_client::client::PortInfoValidationError),
+    #[error("invalid port status: {0}")]
+    InvalidPortStatus(netdevice_client::client::PortStatusValidationError),
+    #[error("unsupported configuration")]
+    ConfigurationNotSupported,
+    #[error("mac {mac} on port {port:?} is not a valid unicast address")]
+    MacNotUnicast { mac: net_types::ethernet::Mac, port: netdevice_client::Port },
+}
+
+const DEFAULT_BUFFER_LENGTH: usize = 2048;
+
+// TODO(https://fxbug.dev/101303): Decorate *all* logging with human-readable
+// device debug information to disambiguate.
+impl NetdeviceWorker {
+    pub async fn new(
+        ctx: NetstackContext,
+        device: fidl::endpoints::ClientEnd<fhardware_network::DeviceMarker>,
+    ) -> Result<Self, Error> {
+        let device =
+            netdevice_client::Client::new(device.into_proxy().expect("must be in executor"));
+        let (session, task) = device
+            .primary_session("netstack3", DEFAULT_BUFFER_LENGTH)
+            .await
+            .map_err(Error::Client)?;
+        Ok(Self { ctx, inner: Inner { device, session, state: Default::default() }, task })
+    }
+
+    pub fn new_handler(&self) -> DeviceHandler {
+        DeviceHandler { inner: self.inner.clone() }
+    }
+
+    pub async fn run(self) -> Result<std::convert::Infallible, Error> {
+        let Self { ctx, inner: Inner { device: _, session, state }, task } = self;
+        // Allow buffer shuttling to happen in other threads.
+        let mut task = fuchsia_async::Task::spawn(task).fuse();
+
+        loop {
+            // Extract result into an enum to avoid too much code in  macro.
+            let rx: netdevice_client::Buffer<_> = futures::select! {
+                r = session.recv().fuse() => r.map_err(Error::Client)?,
+                r = task => match r {
+                    Ok(()) => panic!("task should never end cleanly"),
+                    Err(e) => return Err(Error::Client(e))
+                }
+            };
+            let port = rx.port();
+            let id = if let Some(id) = state.lock().await.get(&port) {
+                *id
+            } else {
+                log::debug!("dropping frame for port {:?}, no device mapping available", port);
+                continue;
+            };
+
+            // We don't need the context right now, we'll use it to feed frames.
+            let _ = ctx;
+            todo!(
+                "https://fxbug.dev/48853 failed to receive data on interface {}, data path not implemented",
+                id
+            )
+        }
+    }
+}
+
+pub(crate) struct InterfaceOptions {
+    pub name: Option<String>,
+}
+
+pub(crate) struct DeviceHandler {
+    inner: Inner,
+}
+
+impl DeviceHandler {
+    pub(crate) async fn add_port(
+        &self,
+        ns: &Netstack,
+        InterfaceOptions { name }: InterfaceOptions,
+        port: fhardware_network::PortId,
+    ) -> Result<BindingId, Error> {
+        let port = netdevice_client::Port::from(port);
+
+        let DeviceHandler { inner: Inner { state, device, session: _ } } = self;
+        let port_proxy = device.connect_port(port)?;
+        let netdevice_client::client::PortInfo { id: _, class: device_class, rx_types, tx_types } =
+            port_proxy
+                .get_info()
+                .await
+                .map_err(Error::CantConnectToPort)?
+                .try_into()
+                .map_err(Error::InvalidPortInfo)?;
+
+        // TODO(https://fxbug.dev/100871): support non-ethernet devices.
+        let supports_ethernet_on_rx =
+            rx_types.iter().any(|f| *f == fhardware_network::FrameType::Ethernet);
+        let supports_ethernet_on_tx = tx_types.iter().any(
+            |fhardware_network::FrameTypeSupport { type_, features: _, supported_flags: _ }| {
+                *type_ == fhardware_network::FrameType::Ethernet
+            },
+        );
+        if !(supports_ethernet_on_rx && supports_ethernet_on_tx) {
+            return Err(Error::ConfigurationNotSupported);
+        }
+
+        let netdevice_client::client::PortStatus { flags: _, mtu } = port_proxy
+            .get_status()
+            .await
+            .map_err(Error::CantConnectToPort)?
+            .try_into()
+            .map_err(Error::InvalidPortStatus)?;
+
+        let (mac_proxy, mac_server) =
+            fidl::endpoints::create_proxy::<fhardware_network::MacAddressingMarker>()
+                .map_err(Error::SystemResource)?;
+        let () = port_proxy.get_mac(mac_server).map_err(Error::CantConnectToPort)?;
+
+        let mac_addr = {
+            let fnet::MacAddress { octets } =
+                mac_proxy.get_unicast_address().await.map_err(|e| {
+                    // TODO(https://fxbug.dev/100871): support non-ethernet
+                    // devices.
+                    log::warn!("failed to get unicast address, sending not supported: {:?}", e);
+                    Error::ConfigurationNotSupported
+                })?;
+            let mac = net_types::ethernet::Mac::new(octets);
+            net_types::UnicastAddr::new(mac).ok_or_else(|| {
+                log::error!("{} is not a valid unicast address", mac);
+                Error::MacNotUnicast { mac, port }
+            })?
+        };
+
+        let mut state = state.lock().await;
+        let ctx = &mut ns.ctx.lock().await;
+        let state_entry = match state.entry(port) {
+            Entry::Occupied(occupied) => {
+                log::warn!("attempted to install port {:?} which is already installed", port);
+                return Err(Error::AlreadyInstalled(*occupied.key()));
+            }
+            Entry::Vacant(e) => e,
+        };
+        let core_id = ctx.state.add_ethernet_device(mac_addr, mtu);
+        let _: &mut DeviceId = state_entry.insert(core_id);
+        let make_info = |id| {
+            let name = name.unwrap_or_else(|| format!("eth{}", id));
+            devices::DeviceSpecificInfo::Netdevice(devices::NetdeviceInfo {
+                common_info: devices::CommonInfo {
+                    mtu,
+                    admin_enabled: false,
+                    events: ns.create_interface_event_producer(
+                        id,
+                        crate::bindings::InterfaceProperties {
+                            name: name.clone(),
+                            device_class: fnet_interfaces::DeviceClass::Device(device_class),
+                        },
+                    ),
+                    name,
+                },
+                handler: PortHandler { id, port_id: port, inner: self.inner.clone() },
+                mac: mac_addr,
+                // TODO(https://fxbug.dev/48853): observe link changes. For now,
+                // we assume the link is always offline. Observing link changes
+                // is also how we'll be able to observe port removal.
+                phy_up: false,
+            })
+        };
+
+        Ok(ctx.dispatcher.devices.add_device(core_id, make_info).expect("duplicate core id in set"))
+    }
+}
+
+pub struct PortHandler {
+    id: BindingId,
+    port_id: netdevice_client::Port,
+    inner: Inner,
+}
+
+impl PortHandler {
+    pub(crate) async fn attach(&self) -> Result<(), netdevice_client::Error> {
+        let Self { id: _, port_id, inner: Inner { device: _, session, state: _ } } = self;
+        session.attach(*port_id, [fhardware_network::FrameType::Ethernet]).await
+    }
+
+    pub(crate) async fn detach(&self) -> Result<(), netdevice_client::Error> {
+        let Self { id: _, port_id, inner: Inner { device: _, session, state: _ } } = self;
+        session.detach(*port_id).await
+    }
+
+    pub(crate) fn send(&self, _frame: &[u8]) -> Result<(), netdevice_client::Error> {
+        todo!("https://fxbug.dev/48853 failed to send data on interface {}, data path not implemented", self.id)
+    }
+}
+
+impl std::fmt::Debug for PortHandler {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let Self { id, port_id, inner: _ } = self;
+        f.debug_struct("PortHandler").field("id", id).field("port_id", port_id).finish()
+    }
+}
diff --git a/src/connectivity/network/netstack3/src/bindings/stack_fidl_worker.rs b/src/connectivity/network/netstack3/src/bindings/stack_fidl_worker.rs
index 1b86211..d26728f 100644
--- a/src/connectivity/network/netstack3/src/bindings/stack_fidl_worker.rs
+++ b/src/connectivity/network/netstack3/src/bindings/stack_fidl_worker.rs
@@ -3,7 +3,7 @@
 // found in the LICENSE file.
 
 use super::{
-    devices::{CommonInfo, DeviceSpecificInfo, Devices, EthernetInfo, LoopbackInfo},
+    devices::{CommonInfo, DeviceSpecificInfo, Devices, EthernetInfo},
     ethernet_worker,
     util::{IntoFidl, TryFromFidlWithContext as _, TryIntoCore as _, TryIntoFidlWithContext as _},
     DeviceStatusNotifier, InterfaceControl as _, InterfaceEventProducerFactory, Lockable,
@@ -331,38 +331,14 @@
 {
     fn fidl_enable_interface(mut self, id: u64) -> Result<(), fidl_net_stack::Error> {
         self.ctx.update_device_state(id, |dev_info| {
-            let admin_enabled: &mut bool = match dev_info.info_mut() {
-                DeviceSpecificInfo::Ethernet(EthernetInfo {
-                    common_info: CommonInfo { admin_enabled, mtu: _, events: _, name: _ },
-                    client: _,
-                    mac: _,
-                    features: _,
-                    phy_up: _,
-                }) => admin_enabled,
-                DeviceSpecificInfo::Loopback(LoopbackInfo {
-                    common_info: CommonInfo { admin_enabled, mtu: _, events: _, name: _ },
-                }) => admin_enabled,
-            };
-            *admin_enabled = true;
+            dev_info.info_mut().common_info_mut().admin_enabled = true;
         });
         self.ctx.enable_interface(id)
     }
 
     fn fidl_disable_interface(mut self, id: u64) -> Result<(), fidl_net_stack::Error> {
         self.ctx.update_device_state(id, |dev_info| {
-            let admin_enabled: &mut bool = match dev_info.info_mut() {
-                DeviceSpecificInfo::Ethernet(EthernetInfo {
-                    common_info: CommonInfo { admin_enabled, mtu: _, events: _, name: _ },
-                    client: _,
-                    mac: _,
-                    features: _,
-                    phy_up: _,
-                }) => admin_enabled,
-                DeviceSpecificInfo::Loopback(LoopbackInfo {
-                    common_info: CommonInfo { admin_enabled, mtu: _, events: _, name: _ },
-                }) => admin_enabled,
-            };
-            *admin_enabled = false;
+            dev_info.info_mut().common_info_mut().admin_enabled = false;
         });
         self.ctx.disable_interface(id)
     }
diff --git a/src/connectivity/network/tests/fidl/interfaces-admin/src/lib.rs b/src/connectivity/network/tests/fidl/interfaces-admin/src/lib.rs
index 4d27dd0..51c5183 100644
--- a/src/connectivity/network/tests/fidl/interfaces-admin/src/lib.rs
+++ b/src/connectivity/network/tests/fidl/interfaces-admin/src/lib.rs
@@ -8,12 +8,12 @@
 use fidl_fuchsia_net_stack_ext::FidlReturn as _;
 use fuchsia_async::TimeoutExt as _;
 use futures::{FutureExt as _, StreamExt as _, TryFutureExt as _, TryStreamExt as _};
-use net_declare::{fidl_ip, fidl_subnet, std_ip_v6, std_socket_addr};
+use net_declare::{fidl_ip, fidl_mac, fidl_subnet, std_ip_v6, std_socket_addr};
 use net_types::ip::IpAddress as _;
 use netemul::RealmUdpSocket as _;
 use netstack_testing_common::{
     interfaces,
-    realms::{Netstack2, TestRealmExt as _, TestSandboxExt as _},
+    realms::{Netstack, Netstack2, NetstackVersion, TestRealmExt as _, TestSandboxExt as _},
 };
 use netstack_testing_macros::variants_test;
 use std::collections::{HashMap, HashSet};
@@ -532,16 +532,15 @@
     }
 }
 
-#[fuchsia_async::run_singlethreaded(test)]
-async fn device_control_create_interface() {
-    const NAME: &'static str = "device_control_create_interface";
+#[variants_test]
+async fn device_control_create_interface<N: Netstack>(name: &str) {
     // NB: interface names are limited to fuchsia.net.interfaces/INTERFACE_NAME_LENGTH.
     const IF_NAME: &'static str = "ctrl_create_if";
 
     let sandbox = netemul::TestSandbox::new().expect("create sandbox");
-    let realm = sandbox.create_netstack_realm::<Netstack2, _>(NAME).expect("create realm");
+    let realm = sandbox.create_netstack_realm::<N, _>(name).expect("create realm");
     let endpoint =
-        sandbox.create_endpoint::<netemul::NetworkDevice, _>(NAME).await.expect("create endpoint");
+        sandbox.create_endpoint::<netemul::NetworkDevice, _>(name).await.expect("create endpoint");
     let installer = realm
         .connect_to_protocol::<fidl_fuchsia_net_interfaces_admin::InstallerMarker>()
         .expect("connect to protocol");
@@ -604,19 +603,20 @@
 
 // Tests that when a DeviceControl instance is dropped, all interfaces created
 // from it are dropped as well.
+#[variants_test]
 #[test_case(false; "no_detach")]
 #[test_case(true; "detach")]
-#[fuchsia_async::run_singlethreaded(test)]
-async fn device_control_owns_interfaces_lifetimes(detach: bool) {
-    let name = if detach { "detach" } else { "no_detach" };
-    let name = format!("device_control_owns_interfaces_lifetimes_{}", name);
-    const IP_FRAME_TYPES: [fidl_fuchsia_hardware_network::FrameType; 2] = [
-        fidl_fuchsia_hardware_network::FrameType::Ipv4,
-        fidl_fuchsia_hardware_network::FrameType::Ipv6,
-    ];
+async fn device_control_owns_interfaces_lifetimes<N: Netstack>(name: &str, detach: bool) {
+    if detach && N::VERSION == NetstackVersion::Netstack3 {
+        // TODO(https://fxbug.dev/100867): Run this test when we support
+        // detaching.
+        return;
+    }
 
+    let detach_str = if detach { "detach" } else { "no_detach" };
+    let name = format!("{name}_{detach_str}");
     let sandbox = netemul::TestSandbox::new().expect("create sandbox");
-    let realm = sandbox.create_netstack_realm::<Netstack2, _>(name).expect("create realm");
+    let realm = sandbox.create_netstack_realm::<N, _>(name).expect("create realm");
 
     // Create tun interfaces directly to attach ports to different interfaces.
     let (tun_dev, netdevice_client_end) = create_tun_device();
@@ -666,22 +666,18 @@
                     fidl_fuchsia_net_tun::DevicePortConfig {
                         base: Some(fidl_fuchsia_net_tun::BasePortConfig {
                             id: Some(index),
-                            rx_types: Some(IP_FRAME_TYPES.to_vec()),
-                            tx_types: Some(
-                                IP_FRAME_TYPES
-                                    .iter()
-                                    .copied()
-                                    .map(|type_| fidl_fuchsia_hardware_network::FrameTypeSupport {
-                                        type_,
-                                        features: fidl_fuchsia_hardware_network::FRAME_FEATURES_RAW,
-                                        supported_flags:
-                                            fidl_fuchsia_hardware_network::TxFlags::empty(),
-                                    })
-                                    .collect(),
-                            ),
+                            rx_types: Some(vec![
+                                fidl_fuchsia_hardware_network::FrameType::Ethernet,
+                            ]),
+                            tx_types: Some(vec![fidl_fuchsia_hardware_network::FrameTypeSupport {
+                                type_: fidl_fuchsia_hardware_network::FrameType::Ethernet,
+                                features: fidl_fuchsia_hardware_network::FRAME_FEATURES_RAW,
+                                supported_flags: fidl_fuchsia_hardware_network::TxFlags::empty(),
+                            }]),
                             mtu: Some(netemul::DEFAULT_MTU.into()),
                             ..fidl_fuchsia_net_tun::BasePortConfig::EMPTY
                         }),
+                        mac: Some(fidl_mac!("02:03:04:05:06:07")),
                         ..fidl_fuchsia_net_tun::DevicePortConfig::EMPTY
                     },
                     port_server_end,
@@ -819,6 +815,7 @@
     }
 }
 
+#[variants_test]
 #[test_case(
 fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::DuplicateName;
 "DuplicateName"
@@ -830,14 +827,14 @@
 #[test_case(fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::BadPort; "BadPort")]
 #[test_case(fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::PortClosed; "PortClosed")]
 #[test_case(fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::User; "User")]
-#[fuchsia_async::run_singlethreaded(test)]
-async fn control_terminal_events(
+async fn control_terminal_events<N: Netstack>(
+    name: &str,
     reason: fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason,
 ) {
-    let name = format!("control_terminal_event_{:?}", reason);
+    let name = format!("{}_{:?}", name, reason);
 
     let sandbox = netemul::TestSandbox::new().expect("create sandbox");
-    let realm = sandbox.create_netstack_realm::<Netstack2, _>(&name).expect("create realm");
+    let realm = sandbox.create_netstack_realm::<N, _>(&name).expect("create realm");
 
     let installer = realm
         .connect_to_protocol::<fidl_fuchsia_net_interfaces_admin::InstallerMarker>()
@@ -866,6 +863,7 @@
             .add_port(
                 fidl_fuchsia_net_tun::DevicePortConfig {
                     base: Some(config),
+                    mac: Some(fidl_mac!("02:aa:bb:cc:dd:ee")),
                     ..fidl_fuchsia_net_tun::DevicePortConfig::EMPTY
                 },
                 port_server_end,
@@ -925,6 +923,11 @@
             (control2, vec![KeepResource::Control(control1), KeepResource::Port(port)])
         }
         fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::DuplicateName => {
+            if N::VERSION == NetstackVersion::Netstack3 {
+                // TODO(https://fxbug.dev/84516): Keep track of names properly
+                // in NS3 and reject duplicate interface names.
+                return;
+            }
             let (port1, port1_id) = create_port(base_port_config.clone()).await;
             let if_name = "test_same_name";
             let control1 = {
@@ -985,6 +988,13 @@
             (control, vec![])
         }
         fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::User => {
+            if N::VERSION == NetstackVersion::Netstack3 {
+                // TODO(https://fxbug.dev/88797): Update this test to observe
+                // epitaphs on fuchsia.net.debug once Netstack3 supports it.
+                // It's a bad idea to test this API in terms of the deprecated
+                // one, and not worth it implementing this machinery in NS3.
+                return;
+            }
             let (port, port_id) = create_port(base_port_config).await;
             let control =
                 create_interface(port_id, fidl_fuchsia_net_interfaces_admin::Options::EMPTY);
@@ -1018,12 +1028,10 @@
 }
 
 // Test that destroying a device causes device control instance to close.
-#[fuchsia_async::run_singlethreaded(test)]
-async fn device_control_closes_on_device_close() {
-    let name = "device_control_closes_on_device_close";
-
+#[variants_test]
+async fn device_control_closes_on_device_close<N: Netstack>(name: &str) {
     let sandbox = netemul::TestSandbox::new().expect("create sandbox");
-    let realm = sandbox.create_netstack_realm::<Netstack2, _>(name).expect("create realm");
+    let realm = sandbox.create_netstack_realm::<N, _>(name).expect("create realm");
     let endpoint =
         sandbox.create_endpoint::<netemul::NetworkDevice, _>(name).await.expect("create endpoint");
 
@@ -1069,6 +1077,8 @@
         watcher.try_next().await.expect("watcher error").expect("watcher ended uexpectedly");
 }
 
+// TODO(https://fxbug.dev/48853): Enable in netstack3 once netdevice support is
+// fully in.
 // Tests that interfaces created through installer have a valid datapath.
 #[fuchsia_async::run_singlethreaded(test)]
 async fn installer_creates_datapath() {
@@ -1207,6 +1217,7 @@
     assert_eq!(&buff[..read], payload_bytes);
 }
 
+// TODO(https://fxbug.dev/48853): Enable in netstack3 once we can observe link state.
 #[fuchsia_async::run_singlethreaded(test)]
 async fn control_enable_disable() {
     let name = "control_enable_disable";
@@ -1307,15 +1318,21 @@
         .await;
 }
 
+#[variants_test]
 #[test_case(false; "no_detach")]
 #[test_case(true; "detach")]
-#[fuchsia_async::run_singlethreaded(test)]
-async fn control_owns_interface_lifetime(detach: bool) {
-    let name = if detach { "detach" } else { "no_detach" };
-    let name = format!("control_owns_interface_lifetime_{}", name);
+async fn control_owns_interface_lifetime<N: Netstack>(name: &str, detach: bool) {
+    if detach && N::VERSION == NetstackVersion::Netstack3 {
+        // TODO(https://fxbug.dev/100867): Enable in Netstack3 once detaching is
+        // supported.
+        return;
+    }
+
+    let detach_str = if detach { "detach" } else { "no_detach" };
+    let name = format!("{}_{}", name, detach_str);
 
     let sandbox = netemul::TestSandbox::new().expect("create sandbox");
-    let realm = sandbox.create_netstack_realm::<Netstack2, _>(&name).expect("create realm");
+    let realm = sandbox.create_netstack_realm::<N, _>(&name).expect("create realm");
     let endpoint =
         sandbox.create_endpoint::<netemul::NetworkDevice, _>(&name).await.expect("create endpoint");
     let installer = realm
@@ -1369,14 +1386,23 @@
         ) if id == iface_id
     );
 
-    let debug = realm
-        .connect_to_protocol::<fidl_fuchsia_net_debug::InterfacesMarker>()
-        .expect("connect to protocol");
-    let (debug_control, control_server_end) =
-        fidl_fuchsia_net_interfaces_ext::admin::Control::create_endpoints().expect("create proxy");
-    let () = debug.get_admin(iface_id, control_server_end).expect("get admin");
-    let same_iface_id = debug_control.get_id().await.expect("get id");
-    assert_eq!(same_iface_id, iface_id);
+    let debug_control = if N::VERSION == NetstackVersion::Netstack3 {
+        // TODO(https://fxbug.dev/88797): Observe termination through the debug
+        // handle once we support it. For now, just check that the interface is
+        // removed on detach
+        None
+    } else {
+        let debug = realm
+            .connect_to_protocol::<fidl_fuchsia_net_debug::InterfacesMarker>()
+            .expect("connect to protocol");
+        let (debug_control, control_server_end) =
+            fidl_fuchsia_net_interfaces_ext::admin::Control::create_endpoints()
+                .expect("create proxy");
+        let () = debug.get_admin(iface_id, control_server_end).expect("get admin");
+        let same_iface_id = debug_control.get_id().await.expect("get id");
+        assert_eq!(same_iface_id, iface_id);
+        Some(debug_control)
+    };
 
     if detach {
         let () = control.detach().expect("detach");
@@ -1384,9 +1410,16 @@
         std::mem::drop(control);
         let watcher_fut =
             watcher.select_next_some().map(|event| panic!("unexpected event {:?}", event));
-        let debug_control_fut = debug_control
-            .wait_termination()
-            .map(|event| panic!("unexpected termination {:?}", event));
+
+        let debug_control_fut = if let Some(debug_control) = debug_control {
+            debug_control
+                .wait_termination()
+                .map(|event| panic!("unexpected termination {:?}", event))
+                .left_future()
+        } else {
+            futures::future::pending().right_future()
+        };
+
         let ((), ()) = futures::future::join(watcher_fut, debug_control_fut)
             .on_timeout(
                 fuchsia_async::Time::after(
@@ -1404,14 +1437,16 @@
             fidl_fuchsia_net_interfaces::Event::Removed(id) if id == iface_id
         );
 
-        // The debug control channel is a weak ref, it didn't prevent destruction,
-        // but is closed now.
-        assert_matches::assert_matches!(
-            debug_control.wait_termination().await,
-            fidl_fuchsia_net_interfaces_ext::admin::TerminalError::Terminal(
-                fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::User
-            )
-        );
+        if let Some(debug_control) = debug_control {
+            // The debug control channel is a weak ref, it didn't prevent destruction,
+            // but is closed now.
+            assert_matches::assert_matches!(
+                debug_control.wait_termination().await,
+                fidl_fuchsia_net_interfaces_ext::admin::TerminalError::Terminal(
+                    fidl_fuchsia_net_interfaces_admin::InterfaceRemovedReason::User
+                )
+            );
+        }
     }
 }
 
diff --git a/src/connectivity/network/tests/integration/common/src/realms.rs b/src/connectivity/network/tests/integration/common/src/realms.rs
index c9f5076..ac1bd42 100644
--- a/src/connectivity/network/tests/integration/common/src/realms.rs
+++ b/src/connectivity/network/tests/integration/common/src/realms.rs
@@ -77,6 +77,7 @@
             ],
             NetstackVersion::Netstack3 => &[
                 fnet_interfaces::StateMarker::PROTOCOL_NAME,
+                fnet_interfaces_admin::InstallerMarker::PROTOCOL_NAME,
                 fnet_stack::StackMarker::PROTOCOL_NAME,
                 fposix_socket::ProviderMarker::PROTOCOL_NAME,
                 fnet_debug::InterfacesMarker::PROTOCOL_NAME,