Repository: metalbear-co/mirrord (language: rust)

Issue:
Support and test traffic stealing with IPv6



Discussion / hints:

Fix patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 818a39e3a89..1b99ddc4c65 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -113,6 +113,25 @@ For example, a test which only tests sanity of the ephemeral container feature s
 
 On Linux, running tests may exhaust a large amount of RAM and crash the machine. To prevent this, limit the number of concurrent jobs by running the command with e.g. `-j 4`
 
+### IPv6
+
+Some tests create a single-stack IPv6 service. They can only be run on clusters with IPv6 enabled.
+In order to test IPv6 on a local cluster on macOS, you can use Kind:
+
+1. `brew install kind`
+2. ```shell
+   cat >kind-config.yaml <<EOF
+   kind: Cluster
+   apiVersion: kind.x-k8s.io/v1alpha4
+   networking:
+     ipFamily: ipv6
+     apiServerAddress: 127.0.0.1
+   EOF
+   ```
+3. `kind create cluster --config kind-config.yaml`
+4. When you run `kubectl get svc -o wide --all-namespaces` you should see IPv6 addresses.
+
+
 ### Cleanup
 
 The Kubernetes resources created by the E2E tests are automatically deleted when the test exits. However, you can preserve resources from failed tests for debugging. To do this, set the `MIRRORD_E2E_PRESERVE_FAILED` variable to any value.
diff --git a/changelog.d/2956.added.md b/changelog.d/2956.added.md
new file mode 100644
index 00000000000..5cadfd68a24
--- /dev/null
+++ b/changelog.d/2956.added.md
@@ -0,0 +1,1 @@
+Support for stealing incoming connections that are over IPv6.
diff --git a/mirrord-schema.json b/mirrord-schema.json
index 82a1538b9a7..46306a4ed28 100644
--- a/mirrord-schema.json
+++ b/mirrord-schema.json
@@ -1268,7 +1268,7 @@
     },
     "IncomingFileConfig": {
       "title": "incoming (network)",
-      "description": "Controls the incoming TCP traffic feature.\n\nSee the incoming [reference](https://mirrord.dev/docs/reference/traffic/#incoming) for more details.\n\nIncoming traffic supports 2 modes of operation:\n\n1. Mirror (**default**): Sniffs the TCP data from a port, and forwards a copy to the interested listeners;\n\n2. Steal: Captures the TCP data from a port, and forwards it to the local process, see [`steal`](##steal);\n\n### Minimal `incoming` config\n\n```json { \"feature\": { \"network\": { \"incoming\": \"steal\" } } } ```\n\n### Advanced `incoming` config\n\n```json { \"feature\": { \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000] \"listen_ports\": [[80, 8111]] } } } } ```",
+      "description": "Controls the incoming TCP traffic feature.\n\nSee the incoming [reference](https://mirrord.dev/docs/reference/traffic/#incoming) for more details.\n\nIncoming traffic supports 2 modes of operation:\n\n1. Mirror (**default**): Sniffs the TCP data from a port, and forwards a copy to the interested listeners;\n\n2. Steal: Captures the TCP data from a port, and forwards it to the local process, see [`steal`](##steal);\n\n### Minimal `incoming` config\n\n```json { \"feature\": { \"network\": { \"incoming\": \"steal\" } } } ```\n\n### Advanced `incoming` config\n\n```json { \"feature\": { \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000], \"listen_ports\": [[80, 8111]] } } } } ```",
       "anyOf": [
         {
           "anyOf": [
@@ -1474,6 +1474,14 @@
             }
           ]
         },
+        "ipv6": {
+          "title": "feature.network.ipv6 {#feature-network-dns}",
+          "description": "Enable ipv6 support. Turn on if your application listens to incoming traffic over IPv6.",
+          "type": [
+            "boolean",
+            "null"
+          ]
+        },
         "outgoing": {
           "title": "feature.network.outgoing {#feature-network-outgoing}",
           "anyOf": [
diff --git a/mirrord/agent/src/cli.rs b/mirrord/agent/src/cli.rs
index 6c5b11e65a2..07c8aa97503 100644
--- a/mirrord/agent/src/cli.rs
+++ b/mirrord/agent/src/cli.rs
@@ -1,7 +1,9 @@
 #![deny(missing_docs)]
 
 use clap::{Parser, Subcommand};
-use mirrord_protocol::{MeshVendor, AGENT_NETWORK_INTERFACE_ENV, AGENT_OPERATOR_CERT_ENV};
+use mirrord_protocol::{
+    MeshVendor, AGENT_IPV6_ENV, AGENT_NETWORK_INTERFACE_ENV, AGENT_OPERATOR_CERT_ENV,
+};
 
 const DEFAULT_RUNTIME: &str = "containerd";
 
@@ -50,6 +52,13 @@ pub struct Args {
         env = "MIRRORD_AGENT_IN_SERVICE_MESH"
     )]
     pub is_mesh: bool,
+
+    /// Enable support for IPv6-only clusters
+    ///
+    /// Only when this option is set will take the needed steps to run on an IPv6 single stack
+    /// cluster.
+    #[arg(long, default_value_t = false, env = AGENT_IPV6_ENV)]
+    pub ipv6: bool,
 }
 
 impl Args {
diff --git a/mirrord/agent/src/entrypoint.rs b/mirrord/agent/src/entrypoint.rs
index f345fa0d5c9..fff9ee7192f 100644
--- a/mirrord/agent/src/entrypoint.rs
+++ b/mirrord/agent/src/entrypoint.rs
@@ -1,7 +1,7 @@
 use std::{
     collections::HashMap,
     mem,
-    net::{Ipv4Addr, SocketAddrV4},
+    net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6},
     path::PathBuf,
     sync::{
         atomic::{AtomicU32, Ordering},
@@ -492,11 +492,33 @@ impl ClientConnectionHandler {
 async fn start_agent(args: Args) -> Result<()> {
     trace!("start_agent -> Starting agent with args: {args:?}");
 
-    let listener = TcpListener::bind(SocketAddrV4::new(
+    // listen for client connections
+    let ipv4_listener_result = TcpListener::bind(SocketAddrV4::new(
         Ipv4Addr::UNSPECIFIED,
         args.communicate_port,
     ))
-    .await?;
+    .await;
+
+    let listener = if args.ipv6 && ipv4_listener_result.is_err() {
+        debug!("IPv6 Support enabled, and IPv4 bind failed, binding IPv6 listener");
+        TcpListener::bind(SocketAddrV6::new(
+            Ipv6Addr::UNSPECIFIED,
+            args.communicate_port,
+            0,
+            0,
+        ))
+        .await
+    } else {
+        ipv4_listener_result
+    }?;
+
+    match listener.local_addr() {
+        Ok(addr) => debug!(
+            client_listener_address = addr.to_string(),
+            "Created listener."
+        ),
+        Err(err) => error!(%err, "listener local address error"),
+    }
 
     let state = State::new(&args).await?;
 
@@ -566,13 +588,15 @@ async fn start_agent(args: Args) -> Result<()> {
         let cancellation_token = cancellation_token.clone();
         let watched_task = WatchedTask::new(
             TcpConnectionStealer::TASK_NAME,
-            TcpConnectionStealer::new(stealer_command_rx).and_then(|stealer| async move {
-                let res = stealer.start(cancellation_token).await;
-                if let Err(err) = res.as_ref() {
-                    error!("Stealer failed: {err}");
-                }
-                res
-            }),
+            TcpConnectionStealer::new(stealer_command_rx, args.ipv6).and_then(
+                |stealer| async move {
+                    let res = stealer.start(cancellation_token).await;
+                    if let Err(err) = res.as_ref() {
+                        error!("Stealer failed: {err}");
+                    }
+                    res
+                },
+            ),
         );
         let status = watched_task.status();
         let task = run_thread_in_namespace(
diff --git a/mirrord/agent/src/error.rs b/mirrord/agent/src/error.rs
index ad04e49c8c5..d9ae7cb8b9d 100644
--- a/mirrord/agent/src/error.rs
+++ b/mirrord/agent/src/error.rs
@@ -84,6 +84,10 @@ pub(crate) enum AgentError {
     /// Temporary error for vpn feature
     #[error("Generic error in vpn: {0}")]
     VpnError(String),
+
+    /// When we neither create a redirector for IPv4, nor for IPv6
+    #[error("Could not create a listener for stolen connections")]
+    CannotListenForStolenConnections,
 }
 
 impl From<mpsc::error::SendError<StealerCommand>> for AgentError {
diff --git a/mirrord/agent/src/steal/connection.rs b/mirrord/agent/src/steal/connection.rs
index 463c61f88d0..37435176b8b 100644
--- a/mirrord/agent/src/steal/connection.rs
+++ b/mirrord/agent/src/steal/connection.rs
@@ -1,6 +1,6 @@
 use std::{
     collections::{HashMap, HashSet},
-    net::{IpAddr, Ipv4Addr, SocketAddr},
+    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
 };
 
 use fancy_regex::Regex;
@@ -289,6 +289,9 @@ pub(crate) struct TcpConnectionStealer {
 
     /// Set of active connections stolen by [`Self::port_subscriptions`].
     connections: StolenConnections,
+
+    /// Shen set, the stealer will use IPv6 if needed.
+    support_ipv6: bool,
 }
 
 impl TcpConnectionStealer {
@@ -297,14 +300,21 @@ impl TcpConnectionStealer {
     /// Initializes a new [`TcpConnectionStealer`], but doesn't start the actual work.
     /// You need to call [`TcpConnectionStealer::start`] to do so.
     #[tracing::instrument(level = "trace")]
-    pub(crate) async fn new(command_rx: Receiver<StealerCommand>) -> Result<Self, AgentError> {
+    pub(crate) async fn new(
+        command_rx: Receiver<StealerCommand>,
+        support_ipv6: bool,
+    ) -> Result<Self, AgentError> {
         let config = envy::prefixed("MIRRORD_AGENT_")
             .from_env::<TcpStealerConfig>()
             .unwrap_or_default();
 
         let port_subscriptions = {
-            let redirector =
-                IpTablesRedirector::new(config.stealer_flush_connections, config.pod_ips).await?;
+            let redirector = IpTablesRedirector::new(
+                config.stealer_flush_connections,
+                config.pod_ips,
+                support_ipv6,
+            )
+            .await?;
 
             PortSubscriptions::new(redirector, 4)
         };
@@ -315,6 +325,7 @@ impl TcpConnectionStealer {
             clients: HashMap::with_capacity(8),
             clients_closed: Default::default(),
             connections: StolenConnections::with_capacity(8),
+            support_ipv6,
         })
     }
 
@@ -371,9 +382,14 @@ impl TcpConnectionStealer {
     #[tracing::instrument(level = "trace", skip(self))]
     async fn incoming_connection(&mut self, stream: TcpStream, peer: SocketAddr) -> Result<()> {
         let mut real_address = orig_dst::orig_dst_addr(&stream)?;
+        let localhost = if self.support_ipv6 && real_address.is_ipv6() {
+            IpAddr::V6(Ipv6Addr::LOCALHOST)
+        } else {
+            IpAddr::V4(Ipv4Addr::LOCALHOST)
+        };
         // If we use the original IP we would go through prerouting and hit a loop.
         // localhost should always work.
-        real_address.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST));
+        real_address.set_ip(localhost);
 
         let Some(port_subscription) = self.port_subscriptions.get(real_address.port()).cloned()
         else {
diff --git a/mirrord/agent/src/steal/ip_tables.rs b/mirrord/agent/src/steal/ip_tables.rs
index 5583b485000..68bddb6a406 100644
--- a/mirrord/agent/src/steal/ip_tables.rs
+++ b/mirrord/agent/src/steal/ip_tables.rs
@@ -111,6 +111,18 @@ pub fn new_iptables() -> iptables::IPTables {
     .expect("IPTables initialization may not fail!")
 }
 
+/// wrapper around iptables::new that uses nft or legacy based on env
+pub fn new_ip6tables() -> iptables::IPTables {
+    if let Ok(val) = std::env::var("MIRRORD_AGENT_NFTABLES")
+        && val.to_lowercase() == "true"
+    {
+        iptables::new_with_cmd("/usr/sbin/ip6tables-nft")
+    } else {
+        iptables::new_with_cmd("/usr/sbin/ip6tables-legacy")
+    }
+    .expect("IPTables initialization may not fail!")
+}
+
 impl Debug for IPTablesWrapper {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("IPTablesWrapper")
@@ -140,7 +152,7 @@ impl IPTables for IPTablesWrapper {
         }
     }
 
-    #[tracing::instrument(level = "trace")]
+    #[tracing::instrument(level = tracing::Level::TRACE, skip(self), ret, fields(table_name=%self.table_name))]
     fn create_chain(&self, name: &str) -> Result<()> {
         self.tables
             .new_chain(self.table_name, name)
@@ -220,6 +232,7 @@ where
         ipt: IPT,
         flush_connections: bool,
         pod_ips: Option<&str>,
+        ipv6: bool,
     ) -> Result<Self> {
         let ipt = Arc::new(ipt);
 
@@ -231,6 +244,7 @@ where
                 _ => Redirects::Mesh(MeshRedirect::create(ipt.clone(), vendor, pod_ips)?),
             }
         } else {
+            tracing::trace!(ipv6 = ipv6, "creating standard redirect");
             match StandardRedirect::create(ipt.clone(), pod_ips) {
                 Err(err) => {
                     warn!("Unable to create StandardRedirect chain: {err}");
@@ -280,7 +294,7 @@ where
     /// Adds the redirect rule to iptables.
     ///
     /// Used to redirect packets when mirrord incoming feature is set to `steal`.
-    #[tracing::instrument(level = "trace", skip(self))]
+    #[tracing::instrument(level = tracing::Level::DEBUG, skip(self))]
     pub(super) async fn add_redirect(
         &self,
         redirected_port: Port,
@@ -408,7 +422,7 @@ mod tests {
             .times(1)
             .returning(|_| Ok(()));
 
-        let ipt = SafeIpTables::create(mock, false, None)
+        let ipt = SafeIpTables::create(mock, false, None, false)
             .await
             .expect("Create Failed");
 
@@ -541,7 +555,7 @@ mod tests {
             .times(1)
             .returning(|_| Ok(()));
 
-        let ipt = SafeIpTables::create(mock, false, None)
+        let ipt = SafeIpTables::create(mock, false, None, false)
             .await
             .expect("Create Failed");
 
diff --git a/mirrord/agent/src/steal/ip_tables/output.rs b/mirrord/agent/src/steal/ip_tables/output.rs
index 944bc26f95b..2286469c00c 100644
--- a/mirrord/agent/src/steal/ip_tables/output.rs
+++ b/mirrord/agent/src/steal/ip_tables/output.rs
@@ -20,8 +20,11 @@ where
 {
     const ENTRYPOINT: &'static str = "OUTPUT";
 
+    #[tracing::instrument(skip(ipt), level = tracing::Level::TRACE)]
     pub fn create(ipt: Arc<IPT>, chain_name: String, pod_ips: Option<&str>) -> Result<Self> {
-        let managed = IPTableChain::create(ipt, chain_name)?;
+        let managed = IPTableChain::create(ipt, chain_name.clone()).inspect_err(
+            |e| tracing::error!(%e, "Could not create iptables chain \"{chain_name}\"."),
+        )?;
 
         let exclude_source_ips = pod_ips
             .map(|pod_ips| format!("! -s {pod_ips}"))
diff --git a/mirrord/agent/src/steal/subscriptions.rs b/mirrord/agent/src/steal/subscriptions.rs
index 0ff0e1fa8ea..0468719bc9c 100644
--- a/mirrord/agent/src/steal/subscriptions.rs
+++ b/mirrord/agent/src/steal/subscriptions.rs
@@ -1,16 +1,20 @@
 use std::{
     collections::{hash_map::Entry, HashMap},
-    net::{Ipv4Addr, SocketAddr},
+    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
+    ops::Not,
     sync::Arc,
 };
 
 use dashmap::{mapref::entry::Entry as DashMapEntry, DashMap};
 use mirrord_protocol::{Port, RemoteResult, ResponseError};
-use tokio::net::{TcpListener, TcpStream};
+use tokio::{
+    net::{TcpListener, TcpStream},
+    select,
+};
 
 use super::{
     http::HttpFilter,
-    ip_tables::{new_iptables, IPTablesWrapper, SafeIpTables},
+    ip_tables::{new_ip6tables, new_iptables, IPTablesWrapper, SafeIpTables},
 };
 use crate::{error::AgentError, util::ClientId};
 
@@ -47,19 +51,82 @@ pub trait PortRedirector {
     async fn next_connection(&mut self) -> Result<(TcpStream, SocketAddr), Self::Error>;
 }
 
-/// Implementation of [`PortRedirector`] that manipulates iptables to steal connections by
-/// redirecting TCP packets to inner [`TcpListener`].
-pub(crate) struct IpTablesRedirector {
+/// A TCP listener, together with an iptables wrapper to set rules that send traffic to the
+/// listener.
+pub(crate) struct IptablesListener {
     /// For altering iptables rules.
     iptables: Option<SafeIpTables<IPTablesWrapper>>,
-    /// Whether exisiting connections should be flushed when adding new redirects.
-    flush_connections: bool,
-    /// Port of [`IpTablesRedirector::listener`].
+    /// Port of [`listener`](Self::listener).
     redirect_to: Port,
     /// Listener to which redirect all connections.
     listener: TcpListener,
-
+    /// Optional comma-seperated list of IPs of the pod, originating in the pod's `Status.PodIps`
     pod_ips: Option<String>,
+    /// Whether existing connections should be flushed when adding new redirects.
+    flush_connections: bool,
+    /// Is this for connections incoming over IPv6
+    ipv6: bool,
+}
+
+#[async_trait::async_trait]
+impl PortRedirector for IptablesListener {
+    type Error = AgentError;
+
+    #[tracing::instrument(skip(self), err, ret, level=tracing::Level::DEBUG, fields(self.ipv6 = %self.ipv6))]
+    async fn add_redirection(&mut self, from: Port) -> Result<(), Self::Error> {
+        let iptables = if let Some(iptables) = self.iptables.as_ref() {
+            iptables
+        } else {
+            let safe = crate::steal::ip_tables::SafeIpTables::create(
+                if self.ipv6 {
+                    new_ip6tables()
+                } else {
+                    new_iptables()
+                }
+                .into(),
+                self.flush_connections,
+                self.pod_ips.as_deref(),
+                self.ipv6,
+            )
+            .await?;
+            self.iptables.insert(safe)
+        };
+        iptables.add_redirect(from, self.redirect_to).await
+    }
+
+    async fn remove_redirection(&mut self, from: Port) -> Result<(), Self::Error> {
+        if let Some(iptables) = self.iptables.as_ref() {
+            iptables.remove_redirect(from, self.redirect_to).await?;
+        }
+
+        Ok(())
+    }
+
+    async fn cleanup(&mut self) -> Result<(), Self::Error> {
+        if let Some(iptables) = self.iptables.take() {
+            iptables.cleanup().await?;
+        }
+
+        Ok(())
+    }
+
+    async fn next_connection(&mut self) -> Result<(TcpStream, SocketAddr), Self::Error> {
+        self.listener.accept().await.map_err(Into::into)
+    }
+}
+
+/// Implementation of [`PortRedirector`] that manipulates iptables to steal connections by
+/// redirecting TCP packets to inner [`TcpListener`].
+///
+/// Holds TCP listeners + iptables, for redirecting IPv4 and/or IPv6 connections.
+pub(crate) enum IpTablesRedirector {
+    Ipv4Only(IptablesListener),
+    /// Could be used if IPv6 support is enabled, and we cannot bind an IPv4 address.
+    Ipv6Only(IptablesListener),
+    Dual {
+        ipv4_listener: IptablesListener,
+        ipv6_listener: IptablesListener,
+    },
 }
 
 impl IpTablesRedirector {
@@ -67,28 +134,116 @@ impl IpTablesRedirector {
     /// [`Ipv4Addr::UNSPECIFIED`] address and a random port. This listener will be used to accept
     /// redirected connections.
     ///
+    /// If `support_ipv6` is set, will also listen on IPv6, and a fail to listen over IPv4 will be
+    /// accepted.
+    ///
     /// # Note
     ///
     /// Does not yet alter iptables.
     ///
     /// # Params
     ///
-    /// * `flush_connections` - whether exisitng connections should be flushed when adding new
+    /// * `flush_connections` - whether existing connections should be flushed when adding new
     ///   redirects
     pub(crate) async fn new(
         flush_connections: bool,
         pod_ips: Option<String>,
+        support_ipv6: bool,
     ) -> Result<Self, AgentError> {
-        let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
-        let redirect_to = listener.local_addr()?.port();
-
-        Ok(Self {
-            iptables: None,
-            flush_connections,
-            redirect_to,
-            listener,
-            pod_ips,
-        })
+        let (pod_ips4, pod_ips6) = pod_ips.map_or_else(
+            || (None, None),
+            |ips| {
+                // TODO: probably nicer to split at the client and avoid the conversion to and back
+                //   from a string.
+                let (ip4s, ip6s): (Vec<_>, Vec<_>) = ips.split(',').partition(|ip_str| {
+                    ip_str
+                        .parse::<IpAddr>()
+                        .inspect_err(|e| tracing::warn!(%e, "failed to parse pod IP {ip_str}"))
+                        .as_ref()
+                        .map(IpAddr::is_ipv4)
+                        .unwrap_or_default()
+                });
+                // Convert to options, `None` if vector is empty.
+                (
+                    ip4s.is_empty().not().then(|| ip4s.join(",")),
+                    ip6s.is_empty().not().then(|| ip6s.join(",")),
+                )
+            },
+        );
+        tracing::debug!("pod IPv4 addresses: {pod_ips4:?}, pod IPv6 addresses: {pod_ips6:?}");
+
+        tracing::debug!("Creating IPv4 iptables redirection listener");
+        let listener4 = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).await
+                .inspect_err(
+                    |err| tracing::debug!(%err, "Could not bind IPv4, continuing with IPv6 only."),
+                )
+                .ok()
+                .and_then(|listener| {
+                    let redirect_to = listener
+                        .local_addr()
+                        .inspect_err(
+                            |err| tracing::debug!(%err, "Get IPv4 listener address, continuing with IPv6 only."),
+                        )
+                        .ok()?
+                        .port();
+                    Some(IptablesListener {
+                        iptables: None,
+                        redirect_to,
+                        listener,
+                        pod_ips: pod_ips4,
+                        flush_connections,
+                        ipv6: false,
+                    })
+                });
+        tracing::debug!("Creating IPv6 iptables redirection listener");
+        let listener6 = if support_ipv6 {
+            TcpListener::bind((Ipv6Addr::UNSPECIFIED, 0)).await
+                    .inspect_err(
+                        |err| tracing::debug!(%err, "Could not bind IPv6, continuing with IPv4 only."),
+                    )
+                    .ok()
+                    .and_then(|listener| {
+                        let redirect_to = listener
+                            .local_addr()
+                            .inspect_err(
+                                |err| tracing::debug!(%err, "Get IPv6 listener address, continuing with IPv4 only."),
+                            )
+                            .ok()?
+                            .port();
+                        Some(IptablesListener {
+                            iptables: None,
+                            redirect_to,
+                            listener,
+                            pod_ips: pod_ips6,
+                            flush_connections,
+                            ipv6: true,
+                        })
+                    })
+        } else {
+            None
+        };
+        match (listener4, listener6) {
+            (None, None) => Err(AgentError::CannotListenForStolenConnections),
+            (Some(ipv4_listener), None) => Ok(Self::Ipv4Only(ipv4_listener)),
+            (None, Some(ipv6_listener)) => Ok(Self::Ipv6Only(ipv6_listener)),
+            (Some(ipv4_listener), Some(ipv6_listener)) => Ok(Self::Dual {
+                ipv4_listener,
+                ipv6_listener,
+            }),
+        }
+    }
+
+    pub(crate) fn get_listeners_mut(
+        &mut self,
+    ) -> (Option<&mut IptablesListener>, Option<&mut IptablesListener>) {
+        match self {
+            IpTablesRedirector::Ipv4Only(ipv4_listener) => (Some(ipv4_listener), None),
+            IpTablesRedirector::Ipv6Only(ipv6_listener) => (None, Some(ipv6_listener)),
+            IpTablesRedirector::Dual {
+                ipv4_listener,
+                ipv6_listener,
+            } => (Some(ipv4_listener), Some(ipv6_listener)),
+        }
     }
 }
 
@@ -97,41 +252,53 @@ impl PortRedirector for IpTablesRedirector {
     type Error = AgentError;
 
     async fn add_redirection(&mut self, from: Port) -> Result<(), Self::Error> {
-        let iptables = match self.iptables.as_ref() {
-            Some(iptables) => iptables,
-            None => {
-                let iptables = new_iptables();
-                let safe = SafeIpTables::create(
-                    iptables.into(),
-                    self.flush_connections,
-                    self.pod_ips.as_deref(),
-                )
-                .await?;
-                self.iptables.insert(safe)
-            }
-        };
-
-        iptables.add_redirect(from, self.redirect_to).await
+        let (ipv4_listener, ipv6_listener) = self.get_listeners_mut();
+        if let Some(ip4_listener) = ipv4_listener {
+            tracing::debug!("Adding IPv4 redirection from port {from}");
+            ip4_listener.add_redirection(from).await?;
+        }
+        if let Some(ip6_listener) = ipv6_listener {
+            tracing::debug!("Adding IPv6 redirection from port {from}");
+            ip6_listener.add_redirection(from).await?;
+        }
+        Ok(())
     }
 
     async fn remove_redirection(&mut self, from: Port) -> Result<(), Self::Error> {
-        if let Some(iptables) = self.iptables.as_ref() {
-            iptables.remove_redirect(from, self.redirect_to).await?;
+        let (ipv4_listener, ipv6_listener) = self.get_listeners_mut();
+        if let Some(ip4_listener) = ipv4_listener {
+            ip4_listener.remove_redirection(from).await?;
+        }
+        if let Some(ip6_listener) = ipv6_listener {
+            ip6_listener.remove_redirection(from).await?;
         }
-
         Ok(())
     }
 
     async fn cleanup(&mut self) -> Result<(), Self::Error> {
-        if let Some(iptables) = self.iptables.take() {
-            iptables.cleanup().await?;
+        let (ipv4_listener, ipv6_listener) = self.get_listeners_mut();
+        if let Some(ip4_listener) = ipv4_listener {
+            ip4_listener.cleanup().await?;
+        }
+        if let Some(ip6_listener) = ipv6_listener {
+            ip6_listener.cleanup().await?;
         }
-
         Ok(())
     }
 
     async fn next_connection(&mut self) -> Result<(TcpStream, SocketAddr), Self::Error> {
-        self.listener.accept().await.map_err(Into::into)
+        match self {
+            Self::Dual {
+                ipv4_listener,
+                ipv6_listener,
+            } => {
+                select! {
+                    con = ipv4_listener.next_connection() => con,
+                    con = ipv6_listener.next_connection() => con,
+                }
+            }
+            Self::Ipv4Only(listener) | Self::Ipv6Only(listener) => listener.next_connection().await,
+        }
     }
 }
 
diff --git a/mirrord/config/configuration.md b/mirrord/config/configuration.md
index 93dab79c3c0..3a3f8b4fa57 100644
--- a/mirrord/config/configuration.md
+++ b/mirrord/config/configuration.md
@@ -1266,6 +1266,10 @@ List of ports to mirror/steal traffic from. Other ports will remain local.
 Mutually exclusive with
 [`feature.network.incoming.ignore_ports`](#feature-network-ignore_ports).
 
+### feature.network.ipv6 {#feature-network-dns}
+
+Enable ipv6 support. Turn on if your application listens to incoming traffic over IPv6.
+
 ### feature.network.outgoing {#feature-network-outgoing}
 
 Tunnel outgoing network operations through mirrord.
diff --git a/mirrord/config/src/config/from_env.rs b/mirrord/config/src/config/from_env.rs
index 9770456721a..0f87ef59034 100644
--- a/mirrord/config/src/config/from_env.rs
+++ b/mirrord/config/src/config/from_env.rs
@@ -20,6 +20,10 @@ where
 {
     type Value = T;
 
+    /// Returns:
+    /// - `None` if there is no env var with that name.
+    /// - `Some(Err(ConfigError::InvalidValue{...}))` if the value of the env var cannot be parsed.
+    /// - `Some(Ok(...))` if the env var exists and was parsed successfully.
     fn source_value(self, _context: &mut ConfigContext) -> Option<Result<Self::Value>> {
         std::env::var(self.0).ok().map(|var| {
             var.parse::<Self::Value>()
diff --git a/mirrord/config/src/feature/network.rs b/mirrord/config/src/feature/network.rs
index 2f2b7901aee..227bd82a915 100644
--- a/mirrord/config/src/feature/network.rs
+++ b/mirrord/config/src/feature/network.rs
@@ -6,10 +6,12 @@ use serde::Serialize;
 
 use self::{incoming::*, outgoing::*};
 use crate::{
-    config::{ConfigContext, ConfigError},
+    config::{from_env::FromEnv, source::MirrordConfigSource, ConfigContext, ConfigError},
     util::MirrordToggleableConfig,
 };
 
+const IPV6_ENV_VAR: &str = "MIRRORD_INCOMING_ENABLE_IPV6";
+
 pub mod dns;
 pub mod filter;
 pub mod incoming;
@@ -67,14 +69,26 @@ pub struct NetworkConfig {
     /// ### feature.network.dns {#feature-network-dns}
     #[config(toggleable, nested)]
     pub dns: DnsConfig,
+
+    /// ### feature.network.ipv6 {#feature-network-dns}
+    ///
+    /// Enable ipv6 support. Turn on if your application listens to incoming traffic over IPv6.
+    #[config(env = IPV6_ENV_VAR, default = false)]
+    pub ipv6: bool,
 }
 
 impl MirrordToggleableConfig for NetworkFileConfig {
     fn disabled_config(context: &mut ConfigContext) -> Result<Self::Generated, ConfigError> {
+        let ipv6 = FromEnv::new(IPV6_ENV_VAR)
+            .source_value(context)
+            .transpose()?
+            .unwrap_or_default();
+
         Ok(NetworkConfig {
             incoming: IncomingFileConfig::disabled_config(context)?,
             dns: DnsFileConfig::disabled_config(context)?,
             outgoing: OutgoingFileConfig::disabled_config(context)?,
+            ipv6,
         })
     }
 }
@@ -84,6 +98,7 @@ impl CollectAnalytics for &NetworkConfig {
         analytics.add("incoming", &self.incoming);
         analytics.add("outgoing", &self.outgoing);
         analytics.add("dns", &self.dns);
+        analytics.add("ipv6", self.ipv6);
     }
 }
 
diff --git a/mirrord/config/src/feature/network/incoming.rs b/mirrord/config/src/feature/network/incoming.rs
index d56199e003e..fddd46260d5 100644
--- a/mirrord/config/src/feature/network/incoming.rs
+++ b/mirrord/config/src/feature/network/incoming.rs
@@ -58,7 +58,7 @@ use http_filter::*;
 ///         },
 ///         "port_mapping": [[ 7777, 8888 ]],
 ///         "ignore_localhost": false,
-///         "ignore_ports": [9999, 10000]
+///         "ignore_ports": [9999, 10000],
 ///         "listen_ports": [[80, 8111]]
 ///       }
 ///     }
@@ -96,9 +96,7 @@ impl MirrordConfig for IncomingFileConfig {
                     .unwrap_or_default(),
                 http_filter: HttpFilterFileConfig::default().generate_config(context)?,
                 on_concurrent_steal: FromEnv::new("MIRRORD_OPERATOR_ON_CONCURRENT_STEAL")
-                    .layer(|layer| {
-                        Unstable::new("IncomingFileConfig", "on_concurrent_steal", layer)
-                    })
+                    .layer(|layer| Unstable::new("incoming", "on_concurrent_steal", layer))
                     .source_value(context)
                     .transpose()?
                     .unwrap_or_default(),
@@ -129,9 +127,7 @@ impl MirrordConfig for IncomingFileConfig {
                     .unwrap_or_default(),
                 on_concurrent_steal: FromEnv::new("MIRRORD_OPERATOR_ON_CONCURRENT_STEAL")
                     .or(advanced.on_concurrent_steal)
-                    .layer(|layer| {
-                        Unstable::new("IncomingFileConfig", "on_concurrent_steal", layer)
-                    })
+                    .layer(|layer| Unstable::new("incoming", "on_concurrent_steal", layer))
                     .source_value(context)
                     .transpose()?
                     .unwrap_or_default(),
@@ -149,7 +145,7 @@ impl MirrordToggleableConfig for IncomingFileConfig {
             .unwrap_or_else(|| Ok(IncomingMode::Off))?;
 
         let on_concurrent_steal = FromEnv::new("MIRRORD_OPERATOR_ON_CONCURRENT_STEAL")
-            .layer(|layer| Unstable::new("IncomingFileConfig", "on_concurrent_steal", layer))
+            .layer(|layer| Unstable::new("incoming", "on_concurrent_steal", layer))
             .source_value(context)
             .transpose()?
             .unwrap_or_default();
diff --git a/mirrord/config/src/lib.rs b/mirrord/config/src/lib.rs
index 8ffd1d48c29..055adac98d2 100644
--- a/mirrord/config/src/lib.rs
+++ b/mirrord/config/src/lib.rs
@@ -878,6 +878,7 @@ mod tests {
                         udp: Some(false),
                         ..Default::default()
                     })),
+                    ipv6: None,
                 })),
                 copy_target: None,
                 hostname: None,
diff --git a/mirrord/kube/src/api/container.rs b/mirrord/kube/src/api/container.rs
index b87a088a412..a651dc13458 100644
--- a/mirrord/kube/src/api/container.rs
+++ b/mirrord/kube/src/api/container.rs
@@ -44,10 +44,16 @@ pub struct ContainerParams {
     /// the agent container.
     pub tls_cert: Option<String>,
     pub pod_ips: Option<String>,
+    /// Support IPv6-only clusters
+    pub support_ipv6: bool,
 }
 
 impl ContainerParams {
-    pub fn new(tls_cert: Option<String>, pod_ips: Option<String>) -> ContainerParams {
+    pub fn new(
+        tls_cert: Option<String>,
+        pod_ips: Option<String>,
+        support_ipv6: bool,
+    ) -> ContainerParams {
         let port: u16 = rand::thread_rng().gen_range(30000..=65535);
         let gid: u16 = rand::thread_rng().gen_range(3000..u16::MAX);
 
@@ -64,6 +70,7 @@ impl ContainerParams {
             port,
             tls_cert,
             pod_ips,
+            support_ipv6,
         }
     }
 }
diff --git a/mirrord/kube/src/api/container/job.rs b/mirrord/kube/src/api/container/job.rs
index 907aefffeeb..d9958e6620b 100644
--- a/mirrord/kube/src/api/container/job.rs
+++ b/mirrord/kube/src/api/container/job.rs
@@ -241,12 +241,14 @@ mod test {
     fn targetless() -> Result<(), Box<dyn std::error::Error>> {
         let mut config_context = ConfigContext::default();
         let agent = AgentFileConfig::default().generate_config(&mut config_context)?;
+        let support_ipv6 = false;
         let params = ContainerParams {
             name: "foobar".to_string(),
             port: 3000,
             gid: 13,
             tls_cert: None,
             pod_ips: None,
+            support_ipv6,
         };
 
         let update = JobVariant::new(&agent, &params).as_update();
@@ -298,7 +300,8 @@ mod test {
                                     { "name": "RUST_LOG", "value": agent.log_level },
                                     { "name": "MIRRORD_AGENT_STEALER_FLUSH_CONNECTIONS", "value": agent.flush_connections.to_string() },
                                     { "name": "MIRRORD_AGENT_NFTABLES", "value": agent.nftables.to_string() },
-                                    { "name": "MIRRORD_AGENT_JSON_LOG", "value": Some(agent.json_log.to_string()) }
+                                    { "name": "MIRRORD_AGENT_JSON_LOG", "value": Some(agent.json_log.to_string()) },
+                                    { "name": "MIRRORD_AGENT_SUPPORT_IPV6", "value": Some(support_ipv6.to_string()) }
 
                                 ],
                                 "resources": // Add requests to avoid getting defaulted https://github.com/metalbear-co/mirrord/issues/579
@@ -330,12 +333,14 @@ mod test {
     fn targeted() -> Result<(), Box<dyn std::error::Error>> {
         let mut config_context = ConfigContext::default();
         let agent = AgentFileConfig::default().generate_config(&mut config_context)?;
+        let support_ipv6 = false;
         let params = ContainerParams {
             name: "foobar".to_string(),
             port: 3000,
             gid: 13,
             tls_cert: None,
             pod_ips: None,
+            support_ipv6,
         };
 
         let update = JobTargetedVariant::new(
@@ -432,7 +437,8 @@ mod test {
                                     { "name": "RUST_LOG", "value": agent.log_level },
                                     { "name": "MIRRORD_AGENT_STEALER_FLUSH_CONNECTIONS", "value": agent.flush_connections.to_string() },
                                     { "name": "MIRRORD_AGENT_NFTABLES", "value": agent.nftables.to_string() },
-                                    { "name": "MIRRORD_AGENT_JSON_LOG", "value": Some(agent.json_log.to_string()) }
+                                    { "name": "MIRRORD_AGENT_JSON_LOG", "value": Some(agent.json_log.to_string()) },
+                                    { "name": "MIRRORD_AGENT_SUPPORT_IPV6", "value": Some(support_ipv6.to_string()) }
                                 ],
                                 "resources": // Add requests to avoid getting defaulted https://github.com/metalbear-co/mirrord/issues/579
                                 {
diff --git a/mirrord/kube/src/api/container/util.rs b/mirrord/kube/src/api/container/util.rs
index 77f917378ce..23fd752181b 100644
--- a/mirrord/kube/src/api/container/util.rs
+++ b/mirrord/kube/src/api/container/util.rs
@@ -4,7 +4,7 @@ use futures::{AsyncBufReadExt, TryStreamExt};
 use k8s_openapi::api::core::v1::{EnvVar, Pod, Toleration};
 use kube::{api::LogParams, Api};
 use mirrord_config::agent::{AgentConfig, LinuxCapability};
-use mirrord_protocol::{AGENT_NETWORK_INTERFACE_ENV, AGENT_OPERATOR_CERT_ENV};
+use mirrord_protocol::{AGENT_IPV6_ENV, AGENT_NETWORK_INTERFACE_ENV, AGENT_OPERATOR_CERT_ENV};
 use regex::Regex;
 use tracing::warn;
 
@@ -59,6 +59,7 @@ pub(super) fn agent_env(agent: &AgentConfig, params: &&ContainerParams) -> Vec<E
     if let Some(interface) = agent.network_interface.as_ref() {
         env.push((AGENT_NETWORK_INTERFACE_ENV.to_string(), interface.into()));
     }
+    env.push((AGENT_IPV6_ENV.to_string(), params.support_ipv6.to_string()));
     if let Some(timeout) = agent.dns.timeout {
         env.push(("MIRRORD_AGENT_DNS_TIMEOUT".to_string(), timeout.to_string()));
     };
diff --git a/mirrord/kube/src/api/kubernetes.rs b/mirrord/kube/src/api/kubernetes.rs
index 15ac4f86f94..44410f34b74 100644
--- a/mirrord/kube/src/api/kubernetes.rs
+++ b/mirrord/kube/src/api/kubernetes.rs
@@ -166,6 +166,7 @@ impl KubernetesAPI {
         &self,
         target: &TargetConfig,
         tls_cert: Option<String>,
+        support_ipv6: bool,
     ) -> Result<(ContainerParams, Option<RuntimeData>), KubeApiError> {
         let runtime_data = match target.path.as_ref().unwrap_or(&Target::Targetless) {
             Target::Targetless => None,
@@ -187,7 +188,7 @@ impl KubernetesAPI {
                     .join(",")
             });
 
-        let params = ContainerParams::new(tls_cert, pod_ips);
+        let params = ContainerParams::new(tls_cert, pod_ips, support_ipv6);
 
         Ok((params, runtime_data))
     }
@@ -209,7 +210,12 @@ impl KubernetesAPI {
     where
         P: Progress + Send + Sync,
     {
-        let (params, runtime_data) = self.create_agent_params(target, tls_cert).await?;
+        let support_ipv6 = config
+            .map(|layer_conf| layer_conf.feature.network.ipv6)
+            .unwrap_or_default();
+        let (params, runtime_data) = self
+            .create_agent_params(target, tls_cert, support_ipv6)
+            .await?;
         if let Some(RuntimeData {
             guessed_container: true,
             container_name,
diff --git a/mirrord/kube/src/api/runtime.rs b/mirrord/kube/src/api/runtime.rs
index a77afd1b5b5..a431a3b4c03 100644
--- a/mirrord/kube/src/api/runtime.rs
+++ b/mirrord/kube/src/api/runtime.rs
@@ -3,7 +3,7 @@ use std::{
     collections::BTreeMap,
     convert::Infallible,
     fmt::{self, Display, Formatter},
-    net::Ipv4Addr,
+    net::IpAddr,
     ops::FromResidual,
     str::FromStr,
 };
@@ -71,7 +71,7 @@ impl Display for ContainerRuntime {
 #[derive(Debug)]
 pub struct RuntimeData {
     pub pod_name: String,
-    pub pod_ips: Vec<Ipv4Addr>,
+    pub pod_ips: Vec<IpAddr>,
     pub pod_namespace: Option<String>,
     pub node_name: String,
     pub container_id: String,
@@ -128,9 +128,9 @@ impl RuntimeData {
             .filter_map(|pod_ip| {
                 pod_ip
                     .ip
-                    .parse::<Ipv4Addr>()
+                    .parse::<IpAddr>()
                     .inspect_err(|e| {
-                        tracing::warn!("failed to parse pod IP {ip}: {e:?}", ip = pod_ip.ip);
+                        tracing::warn!("failed to parse pod IP {ip}: {e:#?}", ip = pod_ip.ip);
                     })
                     .ok()
             })
diff --git a/mirrord/layer/src/socket/ops.rs b/mirrord/layer/src/socket/ops.rs
index 543a512629c..efc0095c8a4 100644
--- a/mirrord/layer/src/socket/ops.rs
+++ b/mirrord/layer/src/socket/ops.rs
@@ -4,6 +4,7 @@ use std::{
     collections::HashMap,
     io,
     net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream},
+    ops::Not,
     os::{
         fd::{BorrowedFd, FromRawFd, IntoRawFd},
         unix::io::RawFd,
@@ -129,7 +130,7 @@ pub(super) fn socket(domain: c_int, type_: c_int, protocol: c_int) -> Detour<Raw
         Ok(())
     }?;
 
-    if domain == libc::AF_INET6 {
+    if domain == libc::AF_INET6 && crate::setup().layer_config().feature.network.ipv6.not() {
         return Detour::Error(HookError::SocketUnsuportedIpv6);
     }
 
diff --git a/mirrord/protocol/Cargo.toml b/mirrord/protocol/Cargo.toml
index 1904cc97f1c..7d787e1e5c6 100644
--- a/mirrord/protocol/Cargo.toml
+++ b/mirrord/protocol/Cargo.toml
@@ -18,12 +18,12 @@ workspace = true
 
 [dependencies]
 actix-codec.workspace = true
+bincode.workspace = true
 bytes.workspace = true
 thiserror.workspace = true
 hickory-resolver.workspace = true
 hickory-proto.workspace = true
 serde.workspace = true
-bincode.workspace = true
 tracing.workspace = true
 hyper = { workspace = true, features = ["client"] }
 http-serde = "2"
diff --git a/mirrord/protocol/src/lib.rs b/mirrord/protocol/src/lib.rs
index ed719c5a9a4..94a555ce0da 100644
--- a/mirrord/protocol/src/lib.rs
+++ b/mirrord/protocol/src/lib.rs
@@ -111,3 +111,5 @@ impl FromStr for MeshVendor {
 pub const AGENT_OPERATOR_CERT_ENV: &str = "MIRRORD_AGENT_OPERATOR_CERT";
 
 pub const AGENT_NETWORK_INTERFACE_ENV: &str = "MIRRORD_AGENT_INTERFACE";
+
+pub const AGENT_IPV6_ENV: &str = "MIRRORD_AGENT_SUPPORT_IPV6";


Test patch:
diff --git a/tests/ipv6-app.yaml b/tests/ipv6-app.yaml
new file mode 100644
index 00000000000..9a044cbde4d
--- /dev/null
+++ b/tests/ipv6-app.yaml
@@ -0,0 +1,49 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: py-serv-deployment
+  labels:
+    app: py-serv
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: py-serv
+  template:
+    metadata:
+      labels:
+        app: py-serv
+    spec:
+      containers:
+        - name: py-serv
+          image: ghcr.io/metalbear-co/mirrord-pytest:latest
+          ports:
+            - containerPort: 80
+          env:
+            - name: MIRRORD_FAKE_VAR_FIRST
+              value: mirrord.is.running
+            - name: MIRRORD_FAKE_VAR_SECOND
+              value: "7777"
+            - name: HOST
+              value: "::"
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    app: py-serv
+  name: py-serv
+spec:
+  ipFamilyPolicy: SingleStack
+  ipFamilies:
+    - IPv6
+  ports:
+    - port: 80
+      protocol: TCP
+      targetPort: 80
+      nodePort: 30000
+  selector:
+    app: py-serv
+  sessionAffinity: None
+  type: NodePort
diff --git a/tests/kind-cluster-ipv6-config.yaml b/tests/kind-cluster-ipv6-config.yaml
new file mode 100644
index 00000000000..29898284d86
--- /dev/null
+++ b/tests/kind-cluster-ipv6-config.yaml
@@ -0,0 +1,9 @@
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+networking:
+  ipFamily: ipv6
+  apiServerAddress: 127.0.0.1
+containerdConfigPatches:
+  - |-
+    [plugins."io.containerd.grpc.v1.cri".registry]
+      config_path = "/etc/containerd/certs.d"
diff --git a/tests/src/env.rs b/tests/src/env.rs
index 7e192ad9f94..8f4b4a8e919 100644
--- a/tests/src/env.rs
+++ b/tests/src/env.rs
@@ -36,7 +36,7 @@ mod env_tests {
         let service = service.await;
         let mut process = run_exec_with_target(
             application.command(),
-            &service.target,
+            &service.pod_container_target(),
             None,
             application.mirrord_args(),
             None,
diff --git a/tests/src/file_ops.rs b/tests/src/file_ops.rs
index 826b6b64ec9..4b67092b974 100644
--- a/tests/src/file_ops.rs
+++ b/tests/src/file_ops.rs
@@ -33,7 +33,7 @@ mod file_ops_tests {
         let env = vec![("MIRRORD_FILE_READ_WRITE_PATTERN", "/tmp/**")];
         let mut process = run_exec_with_target(
             command,
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(args),
             Some(env),
@@ -63,7 +63,7 @@ mod file_ops_tests {
 
         let mut process = run_exec_with_target(
             python_command,
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(args),
             Some(env),
@@ -97,7 +97,7 @@ mod file_ops_tests {
 
         let mut process = run_exec_with_target(
             python_command,
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             None,
             None,
@@ -117,8 +117,14 @@ mod file_ops_tests {
     pub async fn bash_file_exists(#[future] service: KubeService) {
         let service = service.await;
         let bash_command = vec!["bash", "bash-e2e/file.sh", "exists"];
-        let mut process =
-            run_exec_with_target(bash_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            bash_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -135,8 +141,14 @@ mod file_ops_tests {
     pub async fn bash_file_read(#[future] service: KubeService) {
         let service = service.await;
         let bash_command = vec!["bash", "bash-e2e/file.sh", "read"];
-        let mut process =
-            run_exec_with_target(bash_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            bash_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -151,8 +163,14 @@ mod file_ops_tests {
         let service = service.await;
         let bash_command = vec!["bash", "bash-e2e/file.sh", "write"];
         let args = vec!["--rw"];
-        let mut process =
-            run_exec_with_target(bash_command, &service.target, None, Some(args), None).await;
+        let mut process = run_exec_with_target(
+            bash_command,
+            &service.pod_container_target(),
+            None,
+            Some(args),
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -183,7 +201,7 @@ mod file_ops_tests {
 
         let mut process = run_exec_with_target(
             command,
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(args),
             None,
diff --git a/tests/src/http.rs b/tests/src/http.rs
index 388bc6d2c89..e9e23911c3f 100644
--- a/tests/src/http.rs
+++ b/tests/src/http.rs
@@ -41,7 +41,12 @@ mod http_tests {
         let kube_client = kube_client.await;
         let url = get_service_url(kube_client.clone(), &service).await;
         let mut process = application
-            .run(&service.target, Some(&service.namespace), None, None)
+            .run(
+                &service.pod_container_target(),
+                Some(&service.namespace),
+                None,
+                None,
+            )
             .await;
         process
             .wait_for_line(Duration::from_secs(120), "daemon subscribed")
@@ -80,7 +85,12 @@ mod http_tests {
         let kube_client = kube_client.await;
         let url = get_service_url(kube_client.clone(), &service).await;
         let mut process = application
-            .run(&service.target, Some(&service.namespace), None, None)
+            .run(
+                &service.pod_container_target(),
+                Some(&service.namespace),
+                None,
+                None,
+            )
             .await;
         process
             .wait_for_line(Duration::from_secs(300), "daemon subscribed")
diff --git a/tests/src/issue1317.rs b/tests/src/issue1317.rs
index 54b70cde154..28992e0fda2 100644
--- a/tests/src/issue1317.rs
+++ b/tests/src/issue1317.rs
@@ -81,7 +81,14 @@ mod issue1317_tests {
             .to_string_lossy()
             .to_string();
         let executable = vec![app_path.as_str()];
-        let mut process = run_exec_with_target(executable, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            executable,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         process
             .wait_for_line(Duration::from_secs(120), "daemon subscribed")
diff --git a/tests/src/lib.rs b/tests/src/lib.rs
index 98d801f5d23..6e9663bc138 100644
--- a/tests/src/lib.rs
+++ b/tests/src/lib.rs
@@ -1,4 +1,5 @@
 #![feature(stmt_expr_attributes)]
+#![feature(ip)]
 #![warn(clippy::indexing_slicing)]
 
 #[cfg(feature = "cli")]
diff --git a/tests/src/operator/concurrent_steal.rs b/tests/src/operator/concurrent_steal.rs
index f7fcb44a952..8a142c9075e 100644
--- a/tests/src/operator/concurrent_steal.rs
+++ b/tests/src/operator/concurrent_steal.rs
@@ -31,7 +31,7 @@ pub async fn two_clients_steal_same_target(
 
     let mut client_a = application
         .run(
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(flags.clone()),
             None,
@@ -43,7 +43,12 @@ pub async fn two_clients_steal_same_target(
         .await;
 
     let mut client_b = application
-        .run(&service.target, Some(&service.namespace), Some(flags), None)
+        .run(
+            &service.pod_container_target(),
+            Some(&service.namespace),
+            Some(flags),
+            None,
+        )
         .await;
 
     let res = client_b.child.wait().await.unwrap();
@@ -84,7 +89,7 @@ pub async fn two_clients_steal_same_target_pod_deployment(
 
     let mut client_a = application
         .run(
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(flags.clone()),
             None,
@@ -148,7 +153,7 @@ pub async fn two_clients_steal_with_http_filter(
 
     let mut client_a = application
         .run(
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(flags.clone()),
             Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
@@ -164,7 +169,7 @@ pub async fn two_clients_steal_with_http_filter(
 
     let mut client_b = application
         .run(
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(flags),
             Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
diff --git a/tests/src/operator/policies.rs b/tests/src/operator/policies.rs
index 114d42968e6..3684a97fc20 100644
--- a/tests/src/operator/policies.rs
+++ b/tests/src/operator/policies.rs
@@ -282,7 +282,7 @@ async fn run_mirrord_and_verify_steal_result(
     let target = if target_deployment {
         format!("deploy/{}", kube_service.name)
     } else {
-        kube_service.target.clone()
+        kube_service.pod_container_target()
     };
 
     let test_proc = application
@@ -301,7 +301,7 @@ async fn run_mirrord_and_verify_steal_result(
 
     let test_proc = application
         .run(
-            &kube_service.target,
+            &kube_service.pod_container_target(),
             Some(&kube_service.namespace),
             Some(vec!["--config-file", config_path.to_str().unwrap()]),
             None,
@@ -355,7 +355,7 @@ async fn run_mirrord_and_verify_mirror_result(kube_service: &KubeService, expect
 
     let test_proc = application
         .run(
-            &kube_service.target,
+            &kube_service.pod_container_target(),
             Some(&kube_service.namespace),
             Some(vec!["--fs-mode=local"]),
             None,
diff --git a/tests/src/operator/policies/fs.rs b/tests/src/operator/policies/fs.rs
index d54ed7bb98d..afa2cdf62bc 100644
--- a/tests/src/operator/policies/fs.rs
+++ b/tests/src/operator/policies/fs.rs
@@ -59,7 +59,7 @@ pub async fn create_cluster_fs_policy_and_try_file_operations(
 
     let mut test_process = application
         .run(
-            &service.target,
+            &service.pod_container_target(),
             Some(&service.namespace),
             Some(vec!["--fs-mode=write"]),
             None,
diff --git a/tests/src/traffic.rs b/tests/src/traffic.rs
index 66d1df00238..abdaec6404c 100644
--- a/tests/src/traffic.rs
+++ b/tests/src/traffic.rs
@@ -30,8 +30,14 @@ mod traffic_tests {
             "node",
             "node-e2e/remote_dns/test_remote_dns_enabled_works.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -47,8 +53,14 @@ mod traffic_tests {
             "node",
             "node-e2e/remote_dns/test_remote_dns_lookup_google.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -66,8 +78,14 @@ mod traffic_tests {
             "node",
             "node-e2e/outgoing/test_outgoing_traffic_single_request.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -83,8 +101,14 @@ mod traffic_tests {
             "node",
             "node-e2e/outgoing/test_outgoing_traffic_single_request_ipv6.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -102,7 +126,7 @@ mod traffic_tests {
         let mirrord_args = vec!["--no-outgoing"];
         let mut process = run_exec_with_target(
             node_command,
-            &service.target,
+            &service.pod_container_target(),
             None,
             Some(mirrord_args),
             None,
@@ -122,8 +146,14 @@ mod traffic_tests {
             "node",
             "node-e2e/outgoing/test_outgoing_traffic_make_request_after_listen.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
         let res = process.wait().await;
         assert!(res.success());
     }
@@ -137,8 +167,14 @@ mod traffic_tests {
             "node",
             "node-e2e/outgoing/test_outgoing_traffic_make_request_localhost.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
         let res = process.wait().await;
         assert!(res.success());
     }
@@ -185,7 +221,7 @@ mod traffic_tests {
         let mirrord_no_outgoing = vec!["--no-outgoing"];
         let mut process = run_exec_with_target(
             node_command.clone(),
-            &target_service.target,
+            &target_service.pod_container_target(),
             Some(&target_service.namespace),
             Some(mirrord_no_outgoing),
             None,
@@ -193,18 +229,13 @@ mod traffic_tests {
         .await;
         let res = process.wait().await;
         assert!(res.success()); // The test does not fail, because UDP does not report dropped datagrams.
-        let stripped_target = internal_service
-            .target
-            .split('/')
-            .nth(1)
-            .expect("malformed target");
-        let logs = pod_api.logs(stripped_target, &lp).await;
+        let logs = pod_api.logs(&internal_service.pod_name, &lp).await;
         assert_eq!(logs.unwrap(), ""); // Assert that the target service did not get the message.
 
         // Run mirrord with outgoing enabled.
         let mut process = run_exec_with_target(
             node_command,
-            &target_service.target,
+            &target_service.pod_container_target(),
             Some(&target_service.namespace),
             None,
             None,
@@ -217,7 +248,7 @@ mod traffic_tests {
         lp.follow = true; // Follow log stream.
 
         let mut log_lines = pod_api
-            .log_stream(stripped_target, &lp)
+            .log_stream(&internal_service.pod_name, &lp)
             .await
             .unwrap()
             .lines();
@@ -278,7 +309,7 @@ mod traffic_tests {
         // If this verification fails, the test itself is invalid.
         let mut process = run_exec_with_target(
             node_command.clone(),
-            &target_service.target,
+            &target_service.pod_container_target(),
             Some(&target_service.namespace),
             None,
             Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
@@ -286,12 +317,7 @@ mod traffic_tests {
         .await;
         let res = process.wait().await;
         assert!(!res.success()); // Should fail because local process cannot reach service.
-        let stripped_target = internal_service
-            .target
-            .split('/')
-            .nth(1)
-            .expect("malformed target");
-        let logs = pod_api.logs(stripped_target, &lp).await;
+        let logs = pod_api.logs(&internal_service.pod_name, &lp).await;
         assert_eq!(logs.unwrap(), "");
 
         // Create remote filter file with service name so we can test DNS outgoing filter.
@@ -326,7 +352,7 @@ mod traffic_tests {
         // Run mirrord with outgoing enabled.
         let mut process = run_exec_with_target(
             node_command,
-            &target_service.target,
+            &target_service.pod_container_target(),
             Some(&target_service.namespace),
             None,
             Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
@@ -339,7 +365,7 @@ mod traffic_tests {
         lp.follow = true; // Follow log stream.
 
         let mut log_lines = pod_api
-            .log_stream(stripped_target, &lp)
+            .log_stream(&internal_service.pod_name, &lp)
             .await
             .unwrap()
             .lines();
@@ -376,7 +402,7 @@ mod traffic_tests {
         let mirrord_args = vec!["--no-outgoing"];
         let mut process = run_exec_with_target(
             node_command,
-            &service.target,
+            &service.pod_container_target(),
             None,
             Some(mirrord_args),
             None,
@@ -395,7 +421,8 @@ mod traffic_tests {
 
     pub async fn test_go(service: impl Future<Output = KubeService>, command: Vec<&str>) {
         let service = service.await;
-        let mut process = run_exec_with_target(command, &service.target, None, None, None).await;
+        let mut process =
+            run_exec_with_target(command, &service.pod_container_target(), None, None, None).await;
         let res = process.wait().await;
         assert!(res.success());
     }
@@ -458,8 +485,14 @@ mod traffic_tests {
     pub async fn listen_localhost(#[future] service: KubeService) {
         let service = service.await;
         let node_command = vec!["node", "node-e2e/listen/test_listen_localhost.mjs"];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
         let res = process.wait().await;
         assert!(res.success());
     }
@@ -471,8 +504,14 @@ mod traffic_tests {
     pub async fn gethostname_remote_result(#[future] hostname_service: KubeService) {
         let service = hostname_service.await;
         let node_command = vec!["python3", "-u", "python-e2e/hostname.py"];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.wait().await;
         assert!(res.success());
@@ -511,7 +550,9 @@ mod traffic_tests {
             "MIRRORD_OUTGOING_REMOTE_UNIX_STREAMS",
             "/app/unix-socket-server.sock",
         )]);
-        let mut process = run_exec_with_target(executable, &service.target, None, None, env).await;
+        let mut process =
+            run_exec_with_target(executable, &service.pod_container_target(), None, None, env)
+                .await;
         let res = process.wait().await;
 
         // The test application panics if it does not successfully connect to the socket, send data,
@@ -534,7 +575,14 @@ mod traffic_tests {
             .to_string();
         let executable = vec![app_path.as_str()];
 
-        let mut process = run_exec_with_target(executable, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            executable,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
         let res = process.wait().await;
 
         // The test application panics if it does not successfully connect to the socket, send data,
@@ -551,8 +599,14 @@ mod traffic_tests {
             "node",
             "node-e2e/outgoing/test_outgoing_traffic_many_requests.mjs",
         ];
-        let mut process =
-            run_exec_with_target(node_command, &service.target, None, None, None).await;
+        let mut process = run_exec_with_target(
+            node_command,
+            &service.pod_container_target(),
+            None,
+            None,
+            None,
+        )
+        .await;
 
         let res = process.child.wait().await.unwrap();
         assert!(res.success());
@@ -571,7 +625,7 @@ mod traffic_tests {
         let mirrord_args = vec!["--no-outgoing"];
         let mut process = run_exec_with_target(
             node_command,
-            &service.target,
+            &service.pod_container_target(),
             None,
             Some(mirrord_args),
             None,
diff --git a/tests/src/traffic/steal.rs b/tests/src/traffic/steal.rs
index 518aa0bc13e..31b5f668a2b 100644
--- a/tests/src/traffic/steal.rs
+++ b/tests/src/traffic/steal.rs
@@ -9,7 +9,10 @@ mod steal_tests {
     };
 
     use futures_util::{SinkExt, StreamExt};
-    use kube::Client;
+    use hyper::{body, client::conn, Request, StatusCode};
+    use hyper_util::rt::TokioIo;
+    use k8s_openapi::api::core::v1::Pod;
+    use kube::{Api, Client};
     use reqwest::{header::HeaderMap, Url};
     use rstest::*;
     use tokio::time::sleep;
@@ -19,13 +22,13 @@ mod steal_tests {
     };
 
     use crate::utils::{
-        config_dir, get_service_host_and_port, get_service_url, http2_service, kube_client,
-        send_request, send_requests, service, tcp_echo_service, websocket_service, Application,
-        KubeService,
+        config_dir, get_service_host_and_port, get_service_url, http2_service,
+        ipv6::{ipv6_service, portforward_http_requests},
+        kube_client, send_request, send_requests, service, tcp_echo_service, websocket_service,
+        Application, KubeService,
     };
 
     #[cfg_attr(not(any(feature = "ephemeral", feature = "job")), ignore)]
-    #[cfg(target_os = "linux")]
     #[rstest]
     #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
     #[timeout(Duration::from_secs(240))]
@@ -49,7 +52,12 @@ mod steal_tests {
         }
 
         let mut process = application
-            .run(&service.target, Some(&service.namespace), Some(flags), None)
+            .run(
+                &service.pod_container_target(),
+                Some(&service.namespace),
+                Some(flags),
+                None,
+            )
             .await;
 
         process
@@ -63,6 +71,47 @@ mod steal_tests {
         application.assert(&process).await;
     }
 
+    #[ignore] // Needs special cluster setup, so ignore by default.
+    #[rstest]
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    #[timeout(Duration::from_secs(240))]
+    async fn steal_http_ipv6_traffic(
+        #[future] ipv6_service: KubeService,
+        #[future] kube_client: Client,
+    ) {
+        let application = Application::PythonFastApiHTTPIPv6;
+        let service = ipv6_service.await;
+        let kube_client = kube_client.await;
+
+        let mut flags = vec!["--steal"];
+
+        if cfg!(feature = "ephemeral") {
+            flags.extend(["-e"].into_iter());
+        }
+
+        let mut process = application
+            .run(
+                &service.pod_container_target(),
+                Some(&service.namespace),
+                Some(flags),
+                Some(vec![("MIRRORD_INCOMING_ENABLE_IPV6", "true")]),
+            )
+            .await;
+
+        process
+            .wait_for_line(Duration::from_secs(40), "daemon subscribed")
+            .await;
+
+        let api = Api::<Pod>::namespaced(kube_client.clone(), &service.namespace);
+        portforward_http_requests(&api, service).await;
+
+        tokio::time::timeout(Duration::from_secs(40), process.wait())
+            .await
+            .unwrap();
+
+        application.assert(&process).await;
+    }
+
     #[cfg_attr(not(any(feature = "ephemeral", feature = "job")), ignore)]
     #[cfg(target_os = "linux")]
     #[rstest]
@@ -89,7 +138,7 @@ mod steal_tests {
 
         let mut process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_AGENT_STEALER_FLUSH_CONNECTIONS", "true")]),
@@ -125,7 +174,12 @@ mod steal_tests {
         }
 
         let mut process = application
-            .run(&service.target, Some(&service.namespace), Some(flags), None)
+            .run(
+                &service.pod_container_target(),
+                Some(&service.namespace),
+                Some(flags),
+                None,
+            )
             .await;
 
         // Verify that we hooked the socket operations and the agent started stealing.
@@ -208,7 +262,7 @@ mod steal_tests {
 
         let mut process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![
@@ -288,7 +342,7 @@ mod steal_tests {
 
         let mut client = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
@@ -329,7 +383,7 @@ mod steal_tests {
 
         let mut client = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 None,
                 Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
@@ -370,7 +424,7 @@ mod steal_tests {
 
         let mut client = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 None,
                 Some(vec![("MIRRORD_CONFIG_FILE", config_path.to_str().unwrap())]),
@@ -423,7 +477,7 @@ mod steal_tests {
 
         let mut mirrored_process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
@@ -494,7 +548,7 @@ mod steal_tests {
 
         let mut mirrorded_process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
@@ -559,7 +613,7 @@ mod steal_tests {
 
         let mut mirrorded_process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
@@ -629,7 +683,7 @@ mod steal_tests {
 
         let mut mirrorded_process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(flags),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
@@ -708,7 +762,7 @@ mod steal_tests {
 
         let mut mirrorded_process = application
             .run(
-                &service.target,
+                &service.pod_container_target(),
                 Some(&service.namespace),
                 Some(vec!["--steal"]),
                 Some(vec![("MIRRORD_HTTP_HEADER_FILTER", "x-filter: yes")]),
diff --git a/tests/src/utils.rs b/tests/src/utils.rs
index 538a4d753e6..3b596efa357 100644
--- a/tests/src/utils.rs
+++ b/tests/src/utils.rs
@@ -4,7 +4,7 @@
 use std::{
     collections::HashMap,
     fmt::Debug,
-    net::Ipv4Addr,
+    net::IpAddr,
     ops::Not,
     path::PathBuf,
     process::{ExitStatus, Stdio},
@@ -39,6 +39,7 @@ use tokio::{
     task::JoinHandle,
 };
 
+pub(crate) mod ipv6;
 pub mod sqs_resources;
 
 const TEXT: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
@@ -90,6 +91,7 @@ fn format_time() -> String {
 pub enum Application {
     PythonFlaskHTTP,
     PythonFastApiHTTP,
+    PythonFastApiHTTPIPv6,
     NodeHTTP,
     NodeHTTP2,
     Go21HTTP,
@@ -399,6 +401,15 @@ impl Application {
                     "app_fastapi:app",
                 ]
             }
+            Application::PythonFastApiHTTPIPv6 => {
+                vec![
+                    "uvicorn",
+                    "--port=80",
+                    "--host=::",
+                    "--app-dir=./python-e2e/",
+                    "app_fastapi:app",
+                ]
+            }
             Application::PythonCloseSocket => {
                 vec!["python3", "-u", "python-e2e/close_socket.py"]
             }
@@ -447,7 +458,7 @@ impl Application {
     }
 
     pub async fn assert(&self, process: &TestProcess) {
-        if let Application::PythonFastApiHTTP = self {
+        if matches!(self, Self::PythonFastApiHTTP | Self::PythonFastApiHTTPIPv6) {
             process.assert_log_level(true, "ERROR").await;
             process.assert_log_level(false, "ERROR").await;
             process.assert_log_level(true, "CRITICAL").await;
@@ -728,15 +739,19 @@ impl Drop for ResourceGuard {
 pub struct KubeService {
     pub name: String,
     pub namespace: String,
-    pub target: String,
     guards: Vec<ResourceGuard>,
     namespace_guard: Option<ResourceGuard>,
+    pub pod_name: String,
 }
 
 impl KubeService {
     pub fn deployment_target(&self) -> String {
         format!("deployment/{}", self.name)
     }
+
+    pub fn pod_container_target(&self) -> String {
+        format!("pod/{}/container/{CONTAINER_NAME}", self.pod_name)
+    }
 }
 
 impl Drop for KubeService {
@@ -817,6 +832,17 @@ fn deployment_from_json(name: &str, image: &str, env: Value) -> Deployment {
     .expect("Failed creating `deployment` from json spec!")
 }
 
+/// Change the `ipFamilies` and `ipFamilyPolicy` fields to make the service IPv6-only.
+///
+/// # Panics
+///
+/// Will panic if the given service does not have a spec.
+fn set_ipv6_only(service: &mut Service) {
+    let spec = service.spec.as_mut().unwrap();
+    spec.ip_families = Some(vec!["IPv6".to_string()]);
+    spec.ip_family_policy = Some("SingleStack".to_string());
+}
+
 fn service_from_json(name: &str, service_type: &str) -> Service {
     serde_json::from_value(json!({
         "apiVersion": "v1",
@@ -1065,6 +1091,7 @@ pub async fn service(
         randomize_name,
         kube_client.await,
         default_env(),
+        false,
     )
     .await
 }
@@ -1093,6 +1120,7 @@ pub async fn service_with_env(
         randomize_name,
         kube_client,
         env,
+        false,
     )
     .await
 }
@@ -1108,6 +1136,7 @@ pub async fn service_with_env(
 /// This behavior can be changed, see [`PRESERVE_FAILED_ENV_NAME`].
 /// * `randomize_name` - whether a random suffix should be added to the end of the resource names
 /// * `env` - `Value`, should be `Value::Array` of kubernetes container env var definitions.
+#[allow(clippy::too_many_arguments)]
 async fn internal_service(
     namespace: &str,
     service_type: &str,
@@ -1116,6 +1145,7 @@ async fn internal_service(
     randomize_name: bool,
     kube_client: Client,
     env: Value,
+    ipv6_only: bool,
 ) -> KubeService {
     let delete_after_fail = std::env::var_os(PRESERVE_FAILED_ENV_NAME).is_none();
 
@@ -1182,7 +1212,10 @@ async fn internal_service(
     watch_resource_exists(&deployment_api, &name).await;
 
     // `Service`
-    let service = service_from_json(&name, service_type);
+    let mut service = service_from_json(&name, service_type);
+    if ipv6_only {
+        set_ipv6_only(&mut service);
+    }
     let service_guard = ResourceGuard::create(
         service_api.clone(),
         name.clone(),
@@ -1193,13 +1226,13 @@ async fn internal_service(
     .unwrap();
     watch_resource_exists(&service_api, "default").await;
 
-    let target = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
+    let pod_name = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
         .await
         .unwrap();
 
     let pod_api: Api<Pod> = Api::namespaced(kube_client.clone(), namespace);
 
-    await_condition(pod_api, &target, is_pod_running())
+    await_condition(pod_api, &pod_name, is_pod_running())
         .await
         .unwrap();
 
@@ -1211,7 +1244,7 @@ async fn internal_service(
     KubeService {
         name,
         namespace: namespace.to_string(),
-        target: format!("pod/{target}/container/{CONTAINER_NAME}"),
+        pod_name,
         guards: vec![pod_guard, service_guard],
         namespace_guard,
     }
@@ -1304,13 +1337,13 @@ pub async fn service_for_mirrord_ls(
     .unwrap();
     watch_resource_exists(&service_api, "default").await;
 
-    let target = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
+    let pod_name = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
         .await
         .unwrap();
 
     let pod_api: Api<Pod> = Api::namespaced(kube_client.clone(), namespace);
 
-    await_condition(pod_api, &target, is_pod_running())
+    await_condition(pod_api, &pod_name, is_pod_running())
         .await
         .unwrap();
 
@@ -1322,7 +1355,7 @@ pub async fn service_for_mirrord_ls(
     KubeService {
         name,
         namespace: namespace.to_string(),
-        target: format!("pod/{target}/container/{CONTAINER_NAME}"),
+        pod_name,
         guards: vec![pod_guard, service_guard],
         namespace_guard,
     }
@@ -1458,13 +1491,13 @@ pub async fn service_for_mirrord_ls(
             .unwrap();
     watch_resource_exists(&job_api, &name).await;
 
-    let target = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
+    let pod_name = get_instance_name::<Pod>(kube_client.clone(), &name, namespace)
         .await
         .unwrap();
 
     let pod_api: Api<Pod> = Api::namespaced(kube_client.clone(), namespace);
 
-    await_condition(pod_api, &target, is_pod_running())
+    await_condition(pod_api, &pod_name, is_pod_running())
         .await
         .unwrap();
 
@@ -1476,7 +1509,6 @@ pub async fn service_for_mirrord_ls(
     KubeService {
         name,
         namespace: namespace.to_string(),
-        target: format!("pod/{target}/container/{CONTAINER_NAME}"),
         guards: vec![
             pod_guard,
             service_guard,
@@ -1485,6 +1517,7 @@ pub async fn service_for_mirrord_ls(
             job_guard,
         ],
         namespace_guard,
+        pod_name,
     }
 }
 
@@ -1607,12 +1640,15 @@ async fn get_pod_or_node_host(kube_client: Client, name: &str, namespace: &str)
         .next()
         .and_then(|pod| pod.status)
         .and_then(|status| status.host_ip)
-        .and_then(|ip| {
-            ip.parse::<Ipv4Addr>()
-                .unwrap()
-                .is_private()
-                .not()
-                .then_some(ip)
+        .filter(|ip| {
+            // use this IP only if it's a public one.
+            match ip.parse::<IpAddr>().unwrap() {
+                IpAddr::V4(ip4) => ip4.is_private(),
+                IpAddr::V6(ip6) => {
+                    ip6.is_unicast_link_local() || ip6.is_unique_local() || ip6.is_loopback()
+                }
+            }
+            .not()
         })
         .unwrap_or_else(resolve_node_host)
 }
diff --git a/tests/src/utils/ipv6.rs b/tests/src/utils/ipv6.rs
new file mode 100644
index 00000000000..4b766e7f42d
--- /dev/null
+++ b/tests/src/utils/ipv6.rs
@@ -0,0 +1,100 @@
+#![cfg(test)]
+
+use http_body_util::{BodyExt, Empty};
+use hyper::{
+    client::{conn, conn::http1::SendRequest},
+    Request,
+};
+use k8s_openapi::api::core::v1::Pod;
+use kube::{Api, Client};
+use rstest::fixture;
+
+use crate::utils::{internal_service, kube_client, KubeService};
+
+/// Create a new [`KubeService`] and related Kubernetes resources. The resources will be deleted
+/// when the returned service is dropped, unless it is dropped during panic.
+/// This behavior can be changed, see
+/// [`PRESERVE_FAILED_ENV_NAME`](crate::utils::PRESERVE_FAILED_ENV_NAME).
+///
+/// * `randomize_name` - whether a random suffix should be added to the end of the resource names
+#[fixture]
+pub async fn ipv6_service(
+    #[default("default")] namespace: &str,
+    #[default("NodePort")] service_type: &str,
+    #[default("ghcr.io/metalbear-co/mirrord-pytest:latest")] image: &str,
+    #[default("http-echo")] service_name: &str,
+    #[default(true)] randomize_name: bool,
+    #[future] kube_client: Client,
+) -> KubeService {
+    internal_service(
+        namespace,
+        service_type,
+        image,
+        service_name,
+        randomize_name,
+        kube_client.await,
+        serde_json::json!([
+            {
+              "name": "HOST",
+              "value": "::"
+            }
+        ]),
+        true,
+    )
+    .await
+}
+
+/// Send an HTTP request using the referenced `request_sender`, with the provided `method`,
+/// then verify a success status code, and a response body that is the used method.
+///
+/// # Panics
+/// - If the request cannot be sent.
+/// - If the response's code is not OK
+/// - If the response's body is not the method's name.
+pub async fn send_request_with_method(
+    method: &str,
+    request_sender: &mut SendRequest<Empty<hyper::body::Bytes>>,
+) {
+    let req = Request::builder()
+        .method(method)
+        .header("Host", "::")
+        .body(Empty::<hyper::body::Bytes>::new())
+        .unwrap();
+
+    println!("Request: {:?}", req);
+
+    let res = request_sender.send_request(req).await.unwrap();
+    println!("Response: {:?}", res);
+    assert_eq!(res.status(), hyper::StatusCode::OK);
+    let bytes = res.collect().await.unwrap().to_bytes();
+    let response_string = String::from_utf8(bytes.to_vec()).unwrap();
+    assert_eq!(response_string, method);
+}
+
+/// Create a portforward to the pod of the test service, and send HTTP requests over it.
+/// Send four HTTP request (GET, POST, PUT, DELETE), using the referenced `request_sender`, with the
+/// provided `method`, verify OK status, and a response body that is the used method.
+///
+/// # Panics
+/// - If a request cannot be sent.
+/// - If a response's code is not OK
+/// - If a response's body is not the method's name.
+pub async fn portforward_http_requests(api: &Api<Pod>, service: KubeService) {
+    let mut portforwarder = api
+        .portforward(&service.pod_name, &[80])
+        .await
+        .expect("Failed to start portforward to test pod");
+
+    let stream = portforwarder.take_stream(80).unwrap();
+    let stream = hyper_util::rt::TokioIo::new(stream);
+
+    let (mut request_sender, connection) = conn::http1::handshake(stream).await.unwrap();
+    tokio::spawn(async move {
+        if let Err(err) = connection.await {
+            eprintln!("Error in connection from test function to deployed test app {err:#?}");
+        }
+    });
+    for method in ["GET", "POST", "PUT", "DELETE"] {
+        send_request_with_method(method, &mut request_sender).await;
+    }
+}