Skip to content

Dual stack

Two hosts on a single switch, each carrying both an IPv4 /24 and an IPv6 /64 address. The pipeline is the same port-swap forwarder as Quick start — L2 swapping treats v4 and v6 identically. What's interesting is the address management.

What you'll see

pingall (IPv4) and pingall6 (IPv6) both succeed. The hosts' interfaces carry exactly the addresses we asked for — no fe80:: link-local clutter, no SLAAC-derived addresses.

Topology

examples/dual_stack/topology.py:

"""Two hosts plus one switch carrying both IPv4 and IPv6.

The pipeline is L3-agnostic (it just swaps ports 1 and 2), so both v4 and
v6 traverse identically. ``setup(net)`` seeds static ARP and ND so the
hosts don't have to resolve neighbours at run time.

Run with:

    sudo p4net examples/dual_stack/topology.py

Then in the shell:

    pingall
    h1 ping h2
    h1 ping6 h2
"""

from __future__ import annotations

from pathlib import Path

from p4net import Network
from p4net.topo import Topology

HERE = Path(__file__).resolve().parent

topology = Topology()
h1 = topology.add_host(
    "h1",
    ip="10.0.0.1/24",
    mac="00:00:00:00:00:01",
    ip6="fd00::1/64",
)
h2 = topology.add_host(
    "h2",
    ip="10.0.0.2/24",
    mac="00:00:00:00:00:02",
    ip6="fd00::2/64",
)
s1 = topology.add_switch("s1", p4_src=HERE / "dual_stack.p4")
topology.add_link(h1, s1, port_b=1)
topology.add_link(h2, s1, port_b=2)


def setup(net: Network) -> None:
    """Pre-seed static ARP and ND so ICMP unicast doesn't have to resolve."""
    h1 = net.host("h1")
    h2 = net.host("h2")
    h1.exec(
        [
            "ip",
            "neigh",
            "replace",
            "10.0.0.2",
            "lladdr",
            "00:00:00:00:00:02",
            "dev",
            "h1-eth0",
            "nud",
            "permanent",
        ]
    )
    h2.exec(
        [
            "ip",
            "neigh",
            "replace",
            "10.0.0.1",
            "lladdr",
            "00:00:00:00:00:01",
            "dev",
            "h2-eth0",
            "nud",
            "permanent",
        ]
    )
    h1.exec(
        [
            "ip",
            "-6",
            "neigh",
            "replace",
            "fd00::2",
            "lladdr",
            "00:00:00:00:00:02",
            "dev",
            "h1-eth0",
            "nud",
            "permanent",
        ]
    )
    h2.exec(
        [
            "ip",
            "-6",
            "neigh",
            "replace",
            "fd00::1",
            "lladdr",
            "00:00:00:00:00:01",
            "dev",
            "h2-eth0",
            "nud",
            "permanent",
        ]
    )


if __name__ == "__main__":
    from p4net.cli.main import main

    raise SystemExit(main([__file__]))

Both Host.ip and Host.ip6 are set. The orchestrator detects this and runs enable_ipv6(ns, iface) (with accept_ra=0, autoconf=0) before bringing the interface up, then assigns both addresses.

P4 program

examples/dual_stack/dual_stack.p4:

#include <core.p4>
#include <v1model.p4>

header ethernet_t {
    bit<48> dstAddr;
    bit<48> srcAddr;
    bit<16> etherType;
}

struct headers {
    ethernet_t ethernet;
}

struct metadata {}

parser MyParser(packet_in pkt, out headers hdr, inout metadata meta,
                inout standard_metadata_t std) {
    state start {
        pkt.extract(hdr.ethernet);
        transition accept;
    }
}

control MyVerifyChecksum(inout headers hdr, inout metadata meta) { apply {} }

control MyIngress(inout headers hdr, inout metadata meta,
                  inout standard_metadata_t std) {
    apply {
        if (std.ingress_port == 1) {
            std.egress_spec = 2;
        } else if (std.ingress_port == 2) {
            std.egress_spec = 1;
        } else {
            mark_to_drop(std);
        }
    }
}

control MyEgress(inout headers hdr, inout metadata meta,
                 inout standard_metadata_t std) { apply {} }

control MyComputeChecksum(inout headers hdr, inout metadata meta) { apply {} }

control MyDeparser(packet_out pkt, in headers hdr) {
    apply {
        pkt.emit(hdr.ethernet);
    }
}

V1Switch(MyParser(), MyVerifyChecksum(), MyIngress(), MyEgress(),
         MyComputeChecksum(), MyDeparser()) main;

The pipeline is L3-agnostic — it only swaps ports. v4 and v6 traffic take the same path.

Run it

sudo p4net examples/dual_stack/topology.py
p4net> hosts
name  primary_ip   primary_ip6  interfaces
h1    10.0.0.1/24  fd00::1/64   h1-eth0
h2    10.0.0.2/24  fd00::2/64   h2-eth0

p4net> h1 cmd ip -6 addr show dev h1-eth0
3: h1-eth0@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
    inet6 fd00::1/64 scope global
       valid_lft forever preferred_lft forever

p4net> pingall
2/2 succeeded
p4net> pingall6
2/2 succeeded

Note: only fd00::1/64. No fe80:: link-local. That's the sysctl gate doing its job.

What's interesting

  • accept_ra=0 and autoconf=0 are written along with disable_ipv6=0 so the kernel doesn't silently auto-configure additional addresses from a Router Advertisement (which there are none of here, but still).
  • Static ND in setup(net) — with accept_ra off, IPv6 neighbor solicitation still works, but doing it for every cold-start ping wastes time. Pre-seeded entries keep the latency measurements clean.

Variations to try

  • Drop the ip6 arguments from one host and confirm pingall6 excludes it from the matrix (it filters on primary_ip6).
  • Set accept_ra=True in a manual enable_ipv6(...) call and observe what addresses appear. (Requires bypassing the orchestrator.)
  • Add a loss_pct=10.0 link parameter and observe that v4 and v6 pings get the same loss rate (the qdisc is L3-agnostic).