Skip to content

IPv6 LPM

Two IPv6-only hosts on a single switch with an ipv6_lpm table that matches a 128-bit destination address. Programmed at runtime over P4Runtime. The interesting thing is that <switch> table dump renders the IPv6 entries in human form (fd00::1/128) instead of as raw canonical bytes.

What you'll see

pingall6 succeeds, s1 table dump MyIngress.ipv6_lpm shows fd00::1/128 and fd00::2/128, and the per-port counter increments with each ping.

Topology

examples/ipv6_lpm/topology.py:

"""Two IPv6 hosts forwarded by an `ipv6_lpm` table programmed at runtime.

Run with:

    sudo p4net examples/ipv6_lpm/topology.py

Then in the shell:

    pingall6
    h1 ping6 h2
    s1 table dump MyIngress.ipv6_lpm
    s1 counter MyIngress.ipv6_pkts
"""

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", ip6="fd00::1/64", mac="00:00:00:00:00:01")
h2 = topology.add_host("h2", ip6="fd00::2/64", mac="00:00:00:00:00:02")
s1 = topology.add_switch("s1", p4_src=HERE / "ipv6_lpm.p4")
topology.add_link(h1, s1, port_b=1)
topology.add_link(h2, s1, port_b=2)


def setup(net: Network) -> None:
    """Seed static ND and install ipv6_lpm forwarding entries."""
    h1 = net.host("h1")
    h2 = net.host("h2")
    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",
        ]
    )
    s1 = net.switch("s1")
    s1.client.insert_table_entry(
        table="MyIngress.ipv6_lpm",
        match={"hdr.ipv6.dstAddr": "fd00::1/128"},
        action="MyIngress.set_egress_port",
        params={"port": 1},
    )
    s1.client.insert_table_entry(
        table="MyIngress.ipv6_lpm",
        match={"hdr.ipv6.dstAddr": "fd00::2/128"},
        action="MyIngress.set_egress_port",
        params={"port": 2},
    )


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

    raise SystemExit(main([__file__]))

Host.ip6 is the only L3 address — both hosts are IPv6-only. The orchestrator runs enable_ipv6(ns, iface) and assigns fd00::1/64 / fd00::2/64 before bringing the interfaces up.

P4 program

examples/ipv6_lpm/ipv6_lpm.p4:

/* Minimal IPv6 LPM forwarding pipeline.
 *
 * Pairs with examples/ipv6_lpm/topology.py, which programs the table at
 * runtime over P4Runtime. Both v6 endpoints are pre-seeded with static
 * neighbor entries so ICMP unicast does not need to resolve at test time.
 */
#include <core.p4>
#include <v1model.p4>

const bit<16> ETHERTYPE_IPV6 = 0x86DD;

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

header ipv6_t {
    bit<4>   version;
    bit<8>   trafficClass;
    bit<20>  flowLabel;
    bit<16>  payloadLen;
    bit<8>   nextHdr;
    bit<8>   hopLimit;
    bit<128> srcAddr;
    bit<128> dstAddr;
}

struct headers {
    ethernet_t ethernet;
    ipv6_t     ipv6;
}

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 select(hdr.ethernet.etherType) {
            ETHERTYPE_IPV6: parse_ipv6;
            default: accept;
        }
    }
    state parse_ipv6 {
        pkt.extract(hdr.ipv6);
        transition accept;
    }
}

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

control MyIngress(inout headers hdr, inout metadata meta,
                  inout standard_metadata_t std) {
    counter(256, CounterType.packets) ipv6_pkts;

    action drop() {
        mark_to_drop(std);
    }

    action set_egress_port(bit<9> port) {
        std.egress_spec = port;
        ipv6_pkts.count((bit<32>) port);
    }

    table ipv6_lpm {
        key = {
            hdr.ipv6.dstAddr: lpm;
        }
        actions = {
            drop;
            set_egress_port;
            NoAction;
        }
        default_action = NoAction();
        size = 1024;
    }

    apply {
        if (hdr.ipv6.isValid()) {
            ipv6_lpm.apply();
        }
    }
}

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);
        pkt.emit(hdr.ipv6);
    }
}

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

Two notable bits:

  • The match key is bit<128> dstAddr with lpm — the runtime layer stores the canonical bytes plus a prefix length, and decode_match knows to format 128-bit fields as IPv6.
  • set_egress_port bumps an indirect counter so we can verify forwarded traffic from the controller.

Run it

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

p4net> s1 table dump MyIngress.ipv6_lpm
#0
  table:    MyIngress.ipv6_lpm
  match:    {'hdr.ipv6.dstAddr': 'fd00::1/128'}
  action:   MyIngress.set_egress_port
  params:   {'port': '1'}
#1
  table:    MyIngress.ipv6_lpm
  match:    {'hdr.ipv6.dstAddr': 'fd00::2/128'}
  action:   MyIngress.set_egress_port
  params:   {'port': '2'}

p4net> pingall6
H \ H   h1   h2
   h1    -    1
   h2    1    -
2/2 succeeded

p4net> s1 counter MyIngress.ipv6_pkts 2
pkts=1 bytes=118

(That table dump output is captured verbatim from the phase-13 integration test.)

What's interesting

  • Width-aware decoding selects IPv6 format for 128-bit fields. The same decode_match function renders 32-bit fields as IPv4, 48-bit fields as MAC, 128-bit fields as IPv6 condensed form. No per-field annotations needed in the P4Info; the bitwidth is the hint.
  • Canonical bytes round-trip cleanly. encode_value("fd00::1", 128) produces b'\xfd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\x01'. P4Runtime canonicalizes by stripping leading zeros, then BMv2 stores whatever bytes the controller sent. On read, decode_ipv6 zero-extends on the high (most-significant) side back to 16 bytes and feeds ipaddress.IPv6Address.__str__. Verified by the phase-13 integration test.

Variations to try

  • Replace one /128 entry with /64 (covering both hosts) and observe how LPM resolves on the longer prefix when both are installed.
  • Add a third host on fd00::3/64 and install a routing entry from Python at runtime, without touching the P4 source or restarting.
  • Use client.read_counter("MyIngress.ipv6_pkts") from a Python controller to poll counters periodically while traffic flows.