Skip to content

Creating and deploying a Network Function

Network Functions (NFs) are the core building blocks of any service chain. They are responsible for processing and manipulating network traffic as it flows through the service chain. In this section, we will create and deploy a simple firewall network function that will be used in the following sections to filter traffic between a web client and a web server.

Creating the firewall NF in P4

P4 is a domain-specific language for creating programmable data planes. It allows you to define how packets are processed and manipulated as they flow through the network.

Every P4 program consists of a few key components:

  • Headers: define the structure of the packet headers that the program will parse and manipulate.
  • Parsers: define how the program will parse incoming packets and extract header fields.
  • Tables: define the match-action tables that will be used to process packets based on their header fields.
  • Actions: define the actions that will be taken on packets that match certain criteria in the tables.
  • Control flow: define the order in which the tables and actions will be executed.

Libraries

Every P4 program starts by importing the necessary libraries for our P4 program. We will be using the core.p4 library for basic P4 constructs and the v1model.p4 library for the v1model architecture, which is a common architecture for software switches like BMv2.

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

Headers

Moving on, every P4 program needs to define the headers that it will be parsing and manipulating from the packets. This usually depends on the type of traffic that our applications generate and consume.

In IML, every packet will have:

  • an Ethernet header,
  • an outer IPv6 header,
  • a Segment Routing Header (SRH) for SRv6,
  • and an inner IP header (IPv4 or IPv6) that contains the original packet from the source application.

As such, our P4 program needs to define the structure of these headers in order to be able to parse and manipulate them. Here is an example of how these headers can be defined in P4:

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

header ipv6_h {
    bit<4>  version;
    bit<8>  traffic_class;
    bit<20> flow_label;
    bit<16> payload_len;
    bit<8>  next_hdr;
    bit<8>  hop_limit;
    bit<128> src_addr;
    bit<128> dst_addr;
}

header srv6_h {
    bit<8> next_hdr;
    bit<8> hdr_ext_len;
    bit<8> routing_type;
    bit<8> segments_left;
    bit<8> last_entry;
    bit<8> flags;
    bit<16> tag;
}

header segment_h {
    bit<128> segment;
}

struct headers {
    ethernet_h ethernet;
    ipv6_h outer_ipv6;
    srv6_h srh;
    segment_h[MAX_SEGMENTS] segment_list;
    ipv6_h inner_ipv6;
}

Metadata

In addition to headers, P4 programs can also define metadata, which is used to store information about the packet that is not part of the packet headers. This might sound a bit abstract, but you can imagine metadata as a scratch space that the P4 program can use to store information about the packet as it is being processed. This information can then be used later in the program to make decisions about how to process the packet.

In our firewall example, we will define a simple metadata structure that contains an 8 bit field to indicate whether the packet is allowed or not:

struct metadata {
    bool allowed;
}

Parsers

Parsers are used to define how the P4 program will parse incoming packets and extract header fields. In our example, we will define a simple parser that will parse the Ethernet, IPv6, SRH, and inner IPv4 headers from the incoming packets. This parser can be defined as a finite state machine, where each state corresponds to a header that we want to parse. The parser will transition from one state to the next based on the values of the header fields that it extracts. Here's a diagram to illustrate this parser:

stateDiagram-v2
  start: Start
  accept: Accept
  state outer_ether_type <<choice>>
  parse_outer_ipv6: Parse outer IPv6
  state outer_ipv6_proto <<choice>>
  parse_srh: Parse SRH
  parse_segments: Parse Segment
  state segment_amount <<choice>>
  parse_inner_header: Parse inner header
  state inner_header_type <<choice>>
  parse_inner_ipv4: Parse inner IPv4
  start --> outer_ether_type
  outer_ether_type --> parse_outer_ipv6: if etherType = IPV6
  outer_ether_type --> accept: if etherType = other
  parse_outer_ipv6 --> outer_ipv6_proto
  outer_ipv6_proto --> parse_srh: if nextHeader = SRH
  outer_ipv6_proto --> accept: if nextHeader = other
  parse_srh --> parse_segments
  parse_segments --> segment_amount
  segment_amount --> parse_segments: if remaining segments > 0
  segment_amount --> parse_inner_header: if remaining segments = 0
  parse_inner_header --> inner_header_type
  inner_header_type --> parse_inner_ipv4: if nextHeader = IPV4
  inner_header_type --> accept: if nextHeader = other
  parse_inner_ipv4 --> accept

Here is the code of this parser in P4:

parser MyParser(packet_in packet,
                out headers hdr,
                inout metadata_t meta,
                inout standard_metadata_t stdmeta) {
    state start {
        packet.extract(hdr.ethernet);
        transition select(hdr.ethernet.etherType) {
            IPV6_ETHERTYPE: parse_outer_ipv6;
            default: accept;
        }
    }

    state parse_outer_ipv6 {
        packet.extract(hdr.outer_ipv6);
        transition select(hdr.outer_ipv6.next_hdr) {
            IPV6_NEXT_HEADER_ROUTING: parse_srh;
            default: accept;
        }
    }

    state parse_srh {
        packet.extract(hdr.srh);
        transition parse_srh_segments;
    }

    state parse_srh_segments {
        packet.extract(hdr.segment_list.next);
        transition select(hdr.segment_list.lastIndex < (bit<32>)hdr.srh.last_entry) {
            true: parse_srh_segments; // Loop to extract all segments
            false: parse_inner_header;
        }
    }

    state parse_inner_header {
        transition select(hdr.srh.next_hdr) {
            IPV4_NEXT_HEADER: parse_inner_ipv4;
            default: accept;
        }
    }

    state parse_inner_ipv4 {
        packet.extract(hdr.inner_ipv4);
        transition accept;
    }
}

Tables and Actions

Tables and actions are used to define the match-action logic of the P4 program. - Tables are used to match on specific header fields and execute actions based on those matches. - Actions could be considered as the functions that are executed when a packet matches a certain criteria in the table.

In our firewall example, we will define a simple table that matches on the destination IP address of the inner IPv4 header and an action that sets the allowed metadata field to true if the destination IP address is allowed, and false otherwise.

Here's how we can define this table and action in P4:

  action allow() {
      meta.allowed = true;
  }

  action deny() {
      meta.allowed = false;
  }

  table firewall_table {
    key = {
        hdr.inner_ipv4.dst_addr: exact;
    }
    actions = {
        allow;
        deny;
    }
    default_action = deny;
    size = 1024;
  }

Control Flow

Finally, we need to define the control flow of the P4 program, which determines the order in which the tables and actions are executed.

In our firewall example, we will define a simple control flow that applies the firewall table to the packets after they have been parsed. If the packet is allowed, we will forward it to the next hop. If it is denied, we will drop it.

Here's how we can define this control flow in P4:

control MyIngress(inout headers hdr,
                  inout metadata_t meta,
                  inout standard_metadata_t stdmeta) {
  # Previous tables and actions
  # action allow() { ... }
  # action deny() { ... }
  # table firewall_table { ... }

  # Apply the firewall table to the packets
  apply {
    if (!hdr.outer_ipv6.isValid()) {
      return; // If the outer IPv6 header is not valid, skip processing}
    }
    if (!hdr.srh.isValid()) {
      return; // If the SRH header is not valid, skip processing
    }
    if (!hdr.inner_ipv4.isValid()) {
      return // If the inner IPv4 header is not valid, skip processing
    }
    // Here we're sure that the packet has all the necessary headers, so we can apply the firewall table
    firewall_table.apply();
    if (meta.allowed) {
      // If the packet is allowed, forward it to the next hop
      srv6_forward(); // The actual implementation of this function can be found in the finished program below, but it essentially updates the SRH and forwards the packet to the next hop
    } else {
      // If the packet is denied, drop it
      drop();
    }
  }
}

Finished result

Putting everything together, the complete P4 program for our simple firewall network function would look like this:

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

#define MAX_SEGMENTS 8

const bit<16> IPV6_ETHERTYPE = 0x86DD;
const bit<8> IPV6_NEXT_HEADER_ROUTING = 43;
const bit<8> IPV4_NEXT_HEADER = 4;

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

header ipv6_h {
  bit<4>  version;
  bit<8>  traffic_class;
  bit<20> flow_label;
  bit<16> payload_len;
  bit<8>  next_hdr;
  bit<8>  hop_limit;
  bit<128> src_addr;
  bit<128> dst_addr;
}

header ipv6_h {
  bit<4>  version;
  bit<8>  traffic_class;
  bit<20> flow_label;
  bit<16> payload_len;
  bit<8>  next_hdr;
  bit<8>  hop_limit;
  bit<128> src_addr;
  bit<128> dst_addr;
}

header srv6_h {
  bit<8> next_hdr;
  bit<8> hdr_ext_len;
  bit<8> routing_type;
  bit<8> segments_left;
  bit<8> last_entry;
  bit<8> flags;
  bit<16> tag;
}

header segment_h {
  bit<128> segment;
}

struct metadata_t {
  bool allowed;
}

struct headers {
  ethernet_h ethernet;
  ipv6_h outer_ipv6;
  srv6_h srh;
  segment_h[MAX_SEGMENTS] segment_list;
  ipv6_h inner_ipv6;
}

parser MyParser(packet_in packet,
                out headers hdr,
                inout metadata_t meta,
                inout standard_metadata_t stdmeta) {
  state start {
    packet.extract(hdr.ethernet);
    transition select(hdr.ethernet.etherType) {
        IPV6_ETHERTYPE: parse_outer_ipv6;
        default: accept;
    }
  }

  state parse_outer_ipv6 {
    packet.extract(hdr.outer_ipv6);
    transition select(hdr.outer_ipv6.next_hdr) {
        IPV6_NEXT_HEADER_ROUTING: parse_srh;
        default: accept;
    }
  }

  state parse_srh {
    packet.extract(hdr.srh);
    transition parse_srh_segments;
  }

  state parse_srh_segments {
    packet.extract(hdr.segment_list.next);
    transition select(hdr.segment_list.lastIndex < (bit<32>)hdr.srh.last_entry) {
        true: parse_srh_segments; // Loop to extract all segments
        false: parse_inner_header;
    }
  }

  state parse_inner_header {
    transition select(hdr.srh.next_hdr) {
      IPV4_NEXT_HEADER: parse_inner_ipv4;
      default: accept;
    }
  }

  state parse_inner_ipv4 {
      packet.extract(hdr.inner_ipv4);
      transition accept;
  }
}

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

control MyIngress(inout headers hdr,
                  inout metadata_t meta,
                  inout standard_metadata_t stdmeta) {
  action allow() {
    meta.allowed = true;
  }

  action deny() {
    meta.allowed = false;
  }

  action drop() {
    mark_to_drop(stdmeta);
  }

  action srv6_forward() {
    // Apply the "End" SRv6 behavior
    if (hdr.srh.segments_left > 0) {
        hdr.srh.segments_left = hdr.srh.segments_left - 1;
        hdr.outer_ipv6.dst_addr = hdr.segment_list[hdr.srh.segments_left].segment;
    } else {
        mark_to_drop(stdmeta);
    }

    // Change the source and destination MAC addresses
    bit<48> original_src = hdr.ethernet.srcAddr;
    hdr.ethernet.srcAddr = hdr.ethernet.dstAddr;
    hdr.ethernet.dstAddr = original_src;

    // Output the packet on the same port it came in on
    stdmeta.egress_spec = stdmeta.ingress_port;
  }

  table firewall_table {
    key = {
        hdr.inner_ipv4.dst_addr: exact;
    }
    actions = {
        allow;
        deny;
    }
    default_action = deny;
    size = 1024;
  }

  apply {
    if (!hdr.outer_ipv6.isValid()) {
      return; // If the outer IPv6 header is not valid, skip processing}
    }
    if (!hdr.srh.isValid()) {
      return; // If the SRH header is not valid, skip processing
    }
    if (!hdr.inner_ipv4.isValid()) {
      return // If the inner IPv4 header is not valid, skip processing
    }
    // Here we're sure that the packet has all the necessary headers, so we can apply the firewall table
    firewall_table.apply();
    if (meta.allowed) {
      // If the packet is allowed, forward it to the next hop
      srv6_forward(); // The actual implementation of this function can be found in the finished program below, but it essentially updates the SRH and forwards the packet to the next hop
    } else {
      // If the packet is denied, drop it
      drop();
    }
  }
}

control MyEgress(inout headers hdr,
                 inout metadata_t meta,
                 inout standard_metadata_t stdmeta) {
    apply { }
}

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

control MyDeparser(packet_out packet, in headers hdr) {
    apply {
        packet.emit(hdr.ethernet);
        packet.emit(hdr.outer_ipv6);
        packet.emit(hdr.srh);
        packet.emit(hdr.segment_list);
        packet.emit(hdr.inner_ipv6);
    }
}

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

Deploying the NF

Once you have your P4 program ready, and you have uploaded it somewhere accessible (e.g. a Git repository, a web server, an S3 bucket, etc.), you can deploy it by creating a NetworkFunction resource that references the P4 program.

To create a Network Function, you can use the following manifest:

apiVersion: core.loom.io/v1alpha1
kind: NetworkFunction
metadata:
  name: example-firewall
  labels:
    nf: firewall
spec:
  p4File: https://example.org/firewall.p4
  targetSelector:
    matchLabels:
      p4target.loom.io/arch: v1model