Logo

Search

    Overview
    Labs: OVN/OVS DHCP Internals

    Labs: OVN/OVS DHCP Internals

    July 16, 2026
    32 min read

    These labs are designed to accompany the OVN/OVS DHCP Internals deep dive. each lab builds on the previous one, so work through them in order. the goal is to get you comfortable inspecting every layer of DHCP in OVN — from the NB database all the way to the raw packet being constructed and injected back into the datapath.

    if you’ve ever stared at ovn-sbctl lflow-list output wondering why your DHCP flows aren’t showing up, or spent an afternoon debugging why a relay configuration silently does nothing, these labs are for you.


    conventions

    vm topology

    ┌─────────────────────────────────────────────────────────────────┐
    │ OVN Control Plane │
    │ │
    │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
    │ │ ovn-central-01 │ │ ovn-central-02 │ │ ovn-central-03 │ │
    │ │ 192.168.100.11 │ │ 192.168.100.12 │ │ 192.168.100.13 │ │
    │ │ NB + SB + northd│ │ NB + SB + northd│ │ NB + SB + northd│ │
    │ └──────────────┘ └──────────────┘ └──────────────┘ │
    │ │
    │ ┌──────────────┐ ┌──────────────┐ │
    │ │ ovn-compute │ │ ovn-gw │ │
    │ │ 192.168.100.30│ │ 192.168.100.20│ │
    │ │ ovn-controller│ │ovn-controller│ │
    │ └──────────────┘ └──────────────┘ │
    └─────────────────────────────────────────────────────────────────┘

    ssh access

    Terminal window
    ssh root@192.168.100.11 # ovn-central-01 (run ovn-nbctl, ovn-sbctl here)
    ssh root@192.168.100.30 # ovn-compute (run ovs-ofctl, tcpdump here)
    ssh root@192.168.100.20 # ovn-gw (run relay-related captures here)

    logical topology used across labs

    ┌────────┐
    │ R1 │
    │ (router)│
    └──┬───┬─┘
    sw0-p │ │ sw1-p
    ┌─────────┘ └─────────┐
    ┌────┴────┐ ┌────┴────┐
    │ sw0 │ │ sw1 │
    └─┬───┬───┘ └─┬───┬───┘
    vm1│ │vm2 vm3│ │vm4
    10.0.0.1 10.0.0.2 10.0.1.1 10.0.1.2

    key pipeline stages (DHCP)

    PipelineStageNamePurpose
    LS Ingress28ls_in_dhcp_optionsMatch DHCP, encode put_dhcp_opts action
    LS Ingress29ls_in_dhcp_responseIf reg0[3]==1, rewrite L2/L3 and output
    LR Ingress4lr_in_dhcp_relay_reqRewrite src/dst, forward DHCP request to server
    LR Ingress20lr_in_dhcp_relay_resp_chkValidate relay response from server
    LR Ingress21lr_in_dhcp_relay_respRewrite headers, deliver response to client

    register bits

    RegisterNamePurpose
    reg0[3]REGBIT_DHCP_OPTS_RESULTSet to 1 by put_dhcp_opts if encoding succeeds
    reg9[7]REGBIT_DHCP_RELAY_REQ_CHKSet by dhcp_relay_req_chk if relay request valid
    reg9[8]REGBIT_DHCP_RELAY_RESP_CHKSet by dhcp_relay_resp_chk if relay response valid
    reg2REG_DHCP_RELAY_DIP_IPV4Stores original dst IP from relay response

    DHCP options: mandatory vs optional

    three options are mandatory — if missing, build_dhcpv4_action() returns false and no DHCP flows are generated. zero warning, zero error. the flows just don’t appear. this is one of those things that’ll waste hours of your life if you don’t know about it.

    OptionCodePurpose
    server_id54DHCP server identifier (offer IP)
    server_macServer MAC address for L2 response
    lease_time51IP address lease duration

    command reference

    Terminal window
    # DHCP options
    ovn-nbctl dhcp-options-create <cidr>
    ovn-nbctl dhcp-options-list
    ovn-nbctl dhcp-options-del <uuid>
    ovn-nbctl set DHCP_Options <uuid> option:<key>=<value>
    ovn-nbctl get DHCP_Options <uuid> option
    # Port DHCP binding
    ovn-nbctl lsp-set-dhcpv4-options <port> <uuid>
    ovn-nbctl lsp-get-dhcpv4-options <port>
    # DHCP relay
    ovn-nbctl dhcp-relay-create <name> <server-ip>
    ovn-nbctl lrp-set-dhcp-relay <router-port> <relay-uuid>
    ovn-nbctl set logical_switch <ls> other_config:dhcp_relay_port=<port>
    # Logical flows
    ovn-sbctl lflow-list <datapath>
    ovn-sbctl lflow-list <datapath> | grep dhcp
    # Packet trace
    ovn-trace <switch> '<match>'
    # OpenFlow inspection (on compute node)
    ovs-ofctl dump-flows br-int
    ovs-ofctl dump-flows br-int table=OFTABLE_COOKIE
    # Packet capture (on compute node)
    tcpdump -i any -nn -vv 'udp and (port 67 or port 68)'
    tcpdump -i any -nn -vv 'udp and (port 546 or port 547)'
    # CoPP
    ovn-nbctl copp-add <copp-uuid> dhcpv4-opts <meter-name>
    ovn-nbctl meter-add <meter-name> rate=<n> unit=kbps

    lab 1: NB database — DHCP options table and port references

    this is where it all starts. you create a DHCP options record, bind it to a port, and OVN’s northd picks it up to generate flows. if this part doesn’t work, nothing downstream will.

    create a logical switch and vm port

    Terminal window
    # On ovn-central-01
    ovn-nbctl ls-add sw0
    ovn-nbctl lsp-add sw0 vm1
    ovn-nbctl lsp-set-type vm1 ethernet
    ovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"

    create a DHCP options record

    Terminal window
    # Create DHCP options for 10.0.0.0/24 subnet
    ovn-nbctl dhcp-options-create 10.0.0.0/24
    # Returns UUID, e.g.: 0e4992ce-8d4e-4f72-a8d5-0c5b51ab1f3f
    # Store the UUID for later
    DHCP_UUID=$(ovn-nbctl dhcp-options-list | grep -B1 "10.0.0.0/24" | head -1 | tr -d ' ')
    echo "DHCP_UUID=$DHCP_UUID"

    set mandatory options

    Terminal window
    # These three are REQUIRED — without them, no DHCP flows are generated
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_id=10.0.0.1
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01
    ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600

    set optional options

    Terminal window
    ovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0
    ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1
    ovn-nbctl set DHCP_Options $DHCP_UUID option:dns_server=8.8.8.8

    verify the DHCP options record

    Terminal window
    # List all DHCP options
    ovn-nbctl dhcp-options-list
    # Expected: shows the UUID and CIDR
    # Dump full record
    ovn-nbctl list DHCP_Options $DHCP_UUID
    # Expected:
    # cidr : "10.0.0.0/24"
    # options : {server_id="10.0.0.1", server_mac="00:00:00:00:00:01",
    # lease_time="3600", netmask="255.255.255.0", ...}
    # Verify individual options
    ovn-nbctl get DHCP_Options $DHCP_UUID option

    bind DHCP options to port (weak UUID reference)

    Terminal window
    # Bind the DHCP options to vm1's port
    ovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUID
    # Verify the binding
    ovn-nbctl lsp-get-dhcpv4-options vm1
    # Expected: returns the UUID
    # Full port listing shows the reference
    ovn-nbctl lsp-list sw0

    inspect the NB database schema

    Terminal window
    # Examine the DHCP_Options table schema
    ovn-nbctl list-table | grep -A 20 "DHCP_Options"
    # Examine the weak UUID reference on the port
    ovn-nbctl list logical_switch_port vm1 | grep -A 5 dhcpv4

    what you’ll see: the dhcpv4_options column is a weak UUID reference. if you delete the DHCP_Options record, the port’s reference becomes NULL instead of cascading a delete. this is by design — you don’t want deleting a DHCP options record to kill all the ports that reference it.

    test weak reference behavior

    Terminal window
    # Create a second port and bind the same DHCP options
    ovn-nbctl lsp-add sw0 vm2
    ovn-nbctl lsp-set-type vm2 ethernet
    ovn-nbctl lsp-set-addresses vm2 "00:00:00:00:00:02 10.0.0.3"
    ovn-nbctl lsp-set-dhcpv4-options vm2 $DHCP_UUID
    # Verify both ports reference the same DHCP options
    ovn-nbctl lsp-get-dhcpv4-options vm1
    ovn-nbctl lsp-get-dhcpv4-options vm2
    # Both return the same UUID
    # Delete the DHCP options record
    ovn-nbctl dhcp-options-del $DHCP_UUID
    # Verify references are now NULL (not a cascade delete)
    ovn-nbctl lsp-get-dhcpv4-options vm1
    # Expected: empty (NULL)
    ovn-nbctl lsp-get-dhcpv4-options vm2
    # Expected: empty (NULL)
    # Both ports still exist — weak reference didn't cascade
    ovn-nbctl lsp-list sw0

    try it yourself

    create three ports on sw0, all bound to the same DHCP options UUID. verify they all share the reference. then delete the DHCP options and confirm all three references become NULL simultaneously.


    lab 2: DHCP options — wire format and option types

    this one’s about what happens under the hood. OVN stores option names in the NB database, but the SB database has the actual codes and types that get encoded on the wire. it’s a two-layer system that’s easy to miss if you’re only looking at ovn-nbctl output.

    create fresh DHCP options

    Terminal window
    ovn-nbctl ls-add sw0-test
    ovn-nbctl lsp-add sw0-test vm1
    ovn-nbctl lsp-set-type vm1 ethernet
    ovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"
    DHCP_UUID=$(ovn-nbctl dhcp-options-create 10.0.0.0/24 | tr -d ' ')
    # Mandatory options
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_id=10.0.0.1
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01
    ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600
    # Options of various types
    ovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0 # ipv4
    ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1 # ipv4
    ovn-nbctl set DHCP_Options $DHCP_UUID option:dns_server=8.8.8.8 # ipv4
    ovn-nbctl set DHCP_Options $DHCP_UUID option:mtu=1500 # uint16
    ovn-nbctl set DHCP_Options $DHCP_UUID option:default_ttl=64 # uint8
    ovn-nbctl set DHCP_Options $DHCP_UUID option:hostname=vm1.example.com # str
    ovn-nbctl set DHCP_Options $DHCP_UUID option:domain_name=example.com # str
    ovn-nbctl set DHCP_Options $DHCP_UUID option:ip_forward_enable=true # bool
    ovn-nbctl set DHCP_Options $DHCP_UUID option:broadcast_address=10.0.0.255 # ipv4
    # Bind to port
    ovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUID

    verify option codes via SB database

    Terminal window
    # The NB stores option names; northd syncs them to SB with codes
    # Check the SB DHCP options table
    ovn-sbctl list DHCP_Options | grep -E "name|code|type"

    what you’ll see: the SB database has entries like:

    name : "netmask"
    code : 1
    type : "ipv4"
    name : "router"
    code : 3
    type : "ipv4"
    name : "dns_server"
    code : 6
    type : "ipv4"
    name : "mtu"
    code : 26
    type : "uint16"
    name : "default_ttl"
    code : 23
    type : "uint8"
    name : "lease_time"
    code : 51
    type : "uint32"
    name : "server_id"
    code : 54
    type : "ipv4"

    verify the 39 built-in DHCPv4 options

    Terminal window
    # Count the built-in options
    ovn-sbctl list DHCP_Options | grep "name" | wc -l
    # Expected: 39 (for DHCPv4)
    # List all of them
    ovn-sbctl list DHCP_Options | grep "name" | sort

    verify DHCPv6 built-in options

    Terminal window
    # Count the built-in v6 options
    ovn-sbctl list DHCPv6_Options | grep "name" | wc -l
    # Expected: 7
    ovn-sbctl list DHCPv6_Options | grep "name" | sort
    # Expected: ia_addr, server_id, domain_search, dns_server,
    # bootfile_name, bootfile_name_alt, fqdn

    understand option type wire formats

    each type has a distinct encoding in the put_dhcp_opts action:

    TypeWire FormatExample
    ipv44 bytes, network byte order10.0.0.10x0a000001
    uint81 byte640x40
    uint162 bytes, network byte order15000x05dc
    uint324 bytes, network byte order36000x00000e10
    bool1 byte (0 or 1)true0x01
    strRaw bytes, length = strlen"hello"0x68656c6c6f

    verify by inspecting the logical flow action string:

    Terminal window
    # Look at the action for vm1's port
    ovn-sbctl lflow-list sw0-test | grep -A 5 "ls_in_dhcp_options" | head -20

    what you’ll see: the action contains put_dhcp_opts(offerip = 10.0.0.2, netmask = 255.255.255.0, router = 10.0.0.1, ...) — the option names are resolved to their DHCP codes at encoding time.

    try it yourself

    add a classless_static_route option (code 121) and a domain_search_list option (code 119). these use RFC 3442 and RFC 1035 encodings respectively. verify they appear in the logical flow action string and note how the encoding differs from simple types.


    lab 3: northd flow generation — logical flows and register bits

    this is where northd earns its keep. it scans the DHCP options on each port and generates logical flows that encode the response. the register bits (reg0[3] etc.) are the glue between pipeline stages — one stage sets a bit, the next stage checks it.

    set up topology with DHCP

    Terminal window
    # Clean slate
    ovn-nbctl ls-del sw0-test 2>/dev/null
    ovn-nbctl ls-add sw0
    ovn-nbctl lsp-add sw0 vm1
    ovn-nbctl lsp-set-type vm1 ethernet
    ovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"
    DHCP_UUID=$(ovn-nbctl dhcp-options-create 10.0.0.0/24 | tr -d ' ')
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_id=10.0.0.1
    ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01
    ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600
    ovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0
    ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1
    ovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUID

    inspect DHCP logical flows

    Terminal window
    # Show all DHCP-related flows
    ovn-sbctl lflow-list sw0 | grep -i dhcp

    what you’ll see: three flows per port for DHCPv4:

    1. flow at ls_in_dhcp_options (priority 100) — matches DHCP request, executes put_dhcp_opts
    2. flow at ls_in_dhcp_response (priority 100) — if reg0[3]==1, rewrites headers and outputs
    3. flow at ls_out_acl_eval (priority 34000) — ACL bypass for DHCP replies

    plus two default flows (priority 0) at each stage.

    examine flow 1: DHCP options encoding

    Terminal window
    # Show the specific flow at ls_in_dhcp_options
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_dhcp_options" | grep -A 5 "priority=100"

    what you’ll see: the match and action look like:

    match: "inport == \"vm1\" && eth.src == \"00:00:00:00:00:01\" &&
    (ip4.src == {10.0.0.2, 0.0.0.0} &&
    ip4.dst == {10.0.0.1, 255.255.255.255}) &&
    udp.src == 68 && udp.dst == 67"
    action: "reg0[3] = put_dhcp_opts(offerip = 10.0.0.2, netmask = 255.255.255.0,
    router = 10.0.0.1, ...); next;"

    a few things to notice:

    • ip4.src == {10.0.0.2, 0.0.0.0} — handles both DISCOVER (src=0.0.0.0) and renewals (src=offered IP)
    • ip4.dst == {10.0.0.1, 255.255.255.255} — handles both directed and broadcast requests
    • reg0[3] is set to 1 if put_dhcp_opts succeeds, 0 otherwise
    • the action includes next; to continue the pipeline

    examine flow 2: DHCP response generation

    Terminal window
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_dhcp_response" | grep -A 5 "priority=100"

    what you’ll see:

    match: "inport == \"vm1\" && eth.src == \"00:00:00:00:00:01\" &&
    ip4 && udp.src == 68 && udp.dst == 67 && reg0[3]"
    action: "eth.dst = eth.src; eth.src = 00:00:00:00:00:01;
    ip4.src = 10.0.0.1; udp.src = 67; udp.dst = 68;
    outport = inport; flags.loopback = 1; output;"

    this flow only fires if reg0[3] was set to 1 by the previous stage. the action swaps MACs and IPs, sets UDP ports to 67/68, and outputs back to the ingress port. flags.loopback = 1 ensures the packet is sent back to the port it came from.

    examine flow 3: ACL bypass (priority 34000)

    Terminal window
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_out_acl_eval" | grep -A 5 "34000"

    what you’ll see: a high-priority flow ensures DHCP replies bypass ACLs:

    match: "outport == \"vm1\" && eth.src == 00:00:00:00:00:01 &&
    ip4.src == 10.0.0.1 && udp && udp.src == 67 && udp.dst == 68"
    action: "reg1[0] = 1; next;"

    this sets REGBIT_ACL_VERDICT_ALLOW (reg1[0]) = 1, allowing DHCP replies through even with drop ACLs configured. without this, your DHCP responses would get silently dropped the moment you add any egress ACLs. fun times.

    verify default flows (priority 0)

    Terminal window
    # Show default flows at DHCP stages
    ovn-sbctl lflow-list sw0 | grep -B 1 -A 3 "ls_in_dhcp_options.*priority=0"
    ovn-sbctl lflow-list sw0 | grep -B 1 -A 3 "ls_in_dhcp_response.*priority=0"

    what you’ll see: both stages have a priority-0 default flow 1 -> next; that allows non-DHCP packets to pass through without being dropped.

    understand the guards that prevent flow generation

    northd has three guards before generating DHCP flows for a port:

    1. port must be enabled and not a router port
    2. port must have dhcpv4_options or dhcpv6_options configured
    3. DHCP relay must NOT be enabled on this switch
    Terminal window
    # Verify: without DHCP options, no flows are generated
    ovn-nbctl lsp-add sw0 vm-nodhcp
    ovn-nbctl lsp-set-type vm-nodhcp ethernet
    ovn-nbctl lsp-set-addresses vm-nodhcp "00:00:00:00:00:99 10.0.0.99"
    # Check flows — vm-nodhcp should NOT appear in DHCP flows
    ovn-sbctl lflow-list sw0 | grep "vm-nodhcp" | grep dhcp
    # Expected: empty (no DHCP flows for this port)

    verify flow count changes

    Terminal window
    # Count total flows before and after adding DHCP options
    BEFORE=$(ovn-sbctl lflow-list sw0 | wc -l)
    echo "Flows before: $BEFORE"
    # Add DHCP options to vm1 (already done above)
    AFTER=$(ovn-sbctl lflow-list sw0 | wc -l)
    echo "Flows after: $AFTER"
    echo "New DHCP flows: $((AFTER - BEFORE))"
    # Expected: ~5 new flows (2 DHCP stages + 1 ACL bypass + 2 defaults)

    try it yourself

    add a second port vm2 with the same DHCP options. count the flows again. how many new flows were added? verify that each port generates its own set of DHCP flows (the match includes inport and eth.src).


    lab 4: full DORA process — DISCOVER → OFFER → REQUEST → ACK

    the four-step handshake. this is where it gets real — you’ll trace actual packets through the pipeline and see how OVN constructs the DHCP response in pinctrl.

    set up topology

    Terminal window
    # Use the existing topology from Lab 1
    ovn-nbctl ls-list # Verify sw0 exists
    ovn-nbctl lsp-get-dhcpv4-options vm1 # Verify DHCP options are bound

    trace a DHCPDISCOVER

    Terminal window
    # Simulate a DHCPDISCOVER packet from vm1
    # The packet has: src=0.0.0.0, dst=255.255.255.255, udp 68->67
    # DHCP msg_type=DISCOVER(1)
    ovn-trace sw0 'inport == "vm1" && eth.src == 00:00:00:00:00:01 && eth.dst == ff:ff:ff:ff:ff:ff && ip4.src == 0.0.0.0 && ip4.dst == 255.255.255.255 && udp.src == 68 && udp.dst == 67'

    what you’ll see: the trace output shows:

    1. ls_in_dhcp_options (stage 28) — matches at priority 100:

      match: inport == "vm1" && eth.src == "00:00:00:00:00:01" &&
      (ip4.src == {0.0.0.0, 10.0.0.2} && ip4.dst == {10.0.0.1, 255.255.255.255}) &&
      udp.src == 68 && udp.dst == 67
      action: reg0[3] = put_dhcp_opts(offerip = 10.0.0.2, ...); next;
    2. ls_in_dhcp_response (stage 29) — matches at priority 100 because reg0[3] is now 1:

      match: inport == "vm1" && eth.src == "00:00:00:00:00:01" &&
      ip4 && udp.src == 68 && udp.dst == 67 && reg0[3]
      action: eth.dst = eth.src; eth.src = 00:00:00:00:00:01;
      ip4.src = 10.0.0.1; udp.src = 67; udp.dst = 68;
      outport = inport; flags.loopback = 1; output;

    trace a DHCPREQUEST (after DISCOVER)

    Terminal window
    # The client sends a DHCPREQUEST with requested_ip matching the offer
    # In a real scenario, the OFFER contains the offered IP, client then requests it
    ovn-trace sw0 'inport == "vm1" && eth.src == 00:00:00:00:00:01 && eth.dst == ff:ff:ff:ff:ff:ff && ip4.src == 0.0.0.0 && ip4.dst == 255.255.255.255 && udp.src == 68 && udp.dst == 67'

    what you’ll see: same pipeline traversal. the difference is in the DHCP options — the REQUEST has msg_type=REQUEST(3) and requested_ip=10.0.0.2.

    the pinctrl handler (pinctrl_handle_put_dhcp_opts) checks:

    • dhcp_msg_type == DHCP_MSG_REQUESTmsg_type = DHCP_MSG_ACK
    • dhcp_opts.request_ip == *offer_ip10.0.0.2 == 10.0.0.2 → ACK

    trace a directed DHCPREQUEST (renewal)

    Terminal window
    # During renewal, the client already has an IP and sends directed (not broadcast)
    # src=offered_ip, dst=server_ip
    ovn-trace sw0 'inport == "vm1" && eth.src == 00:00:00:00:00:01 && eth.dst == 00:00:00:00:00:01 && ip4.src == 10.0.0.2 && ip4.dst == 10.0.0.1 && udp.src == 68 && udp.dst == 67'

    what you’ll see: the match at ls_in_dhcp_options still matches because:

    • ip4.src == {10.0.0.2, 0.0.0.0} — matches 10.0.0.2 (the renewal case)
    • ip4.dst == {10.0.0.1, 255.255.255.255} — matches 10.0.0.1 (directed case)

    inspect the packet-in mechanism

    the flow action put_dhcp_opts(...) is an OVN action that generates a packet-in to ovn-controller. the opcode is ACTION_OPCODE_PUT_DHCP_OPTS.

    Terminal window
    # On ovn-compute, inspect the OpenFlow rules that handle the packet-in
    ssh root@192.168.100.30 'ovs-ofctl dump-flows br-int table=0 cookie=0/1 2>/dev/null | head -30'

    the packet-in contains:

    • the original DHCP packet
    • userdata with: OXM header (result field reg0[3]), offer IP, encoded DHCP options

    the handler in ovn-controller:

    1. parses the userdata
    2. validates the DHCP packet (htype=0x1, hlen=0x6, magic cookie=0x63825363)
    3. parses options (extracts msg_type, request_ip, server_id)
    4. determines response type (DISCOVER→OFFER, REQUEST→ACK/NAK)
    5. constructs the reply packet
    6. writes success/failure to reg0[3] and resumes the pipeline

    try it yourself

    capture the actual DHCP exchange using tcpdump on the compute node:

    Terminal window
    # On ovn-compute
    ssh root@192.168.100.30 'tcpdump -i any -nn -vv "udp and (port 67 or port 68)" -c 10 &'

    then trigger a DHCP request via ovn-trace. do you see the packet-in in the OVS logs?


    lab 5: DHCP NAK, INFORM, and RELEASE

    not every DHCP exchange is a happy DORA. sometimes the client asks for an IP it shouldn’t have, or just wants some config info without a full lease, or decides to give back its IP. this lab covers those cases.

    trace a DHCPREQUEST with wrong IP → NAK

    Terminal window
    # Client requests IP 10.0.0.99 which doesn't match the offer (10.0.0.2)
    # The trace doesn't show the NAK directly (ovn-trace stops at put_dhcp_opts)
    # But we can reason about what happens
    # First, verify the offer IP
    ovn-nbctl get DHCP_Options $DHCP_UUID option

    what happens in pinctrl (from the writeup):

    case DHCP_MSG_REQUEST: {
    msg_type = DHCP_MSG_ACK;
    if (dhcp_opts.request_ip != *offer_ip) {
    msg_type = DHCP_MSG_NAK; // 10.0.0.99 != 10.0.0.2 → NAK
    }
    break;
    }

    the NAK response has:

    • yiaddr = 0 (no IP offered)
    • siaddr = 0 (no next server)
    • DHCP options are NOT included in NAK messages
    • msg_type = NAK(6)
    Terminal window
    # Verify this by looking at the pinctrl handler
    # On ovn-compute, check logs for NAK activity
    ssh root@192.168.100.30 'grep -i nak /var/log/openvswitch/ovn-controller.log 2>/dev/null | tail -5'

    trace a DHCPINFORM

    Terminal window
    # A DHCPINFORM is sent by a client that already has an IP and just wants configuration
    # msg_type=INFORM(8), ciaddr=<client's IP>
    # The handler maps INFORM → ACK and strips time-lease/netmask/T1/T2 options
    # from the reply (RFC 2131 section 3.4)

    what happens in pinctrl:

    case OVN_DHCP_MSG_INFORM: {
    msg_type = DHCP_MSG_ACK;
    /* Strip lease/netmask/T1/T2 options from reply */
    break;
    }

    trace a DHCPRELEASE

    Terminal window
    # A DHCPRELEASE is sent by a client to release its IP
    # msg_type=RELEASE(7)
    # The handler just logs it — no response is sent

    what happens in pinctrl:

    case OVN_DHCP_MSG_RELEASE:
    /* Just log it */
    break;

    verify OVN DHCP message types

    Terminal window
    # OVN defines additional message types beyond RFC 2131
    # These are in ovn/lib/ovn-l7.h:
    # DHCP_MSG_DISCOVER = 1
    # DHCP_MSG_OFFER = 2
    # DHCP_MSG_REQUEST = 3
    # OVN_DHCP_MSG_DECLINE = 4 (client rejects offered address)
    # DHCP_MSG_ACK = 5
    # DHCP_MSG_NAK = 6
    # OVN_DHCP_MSG_RELEASE = 7
    # OVN_DHCP_MSG_INFORM = 8

    try it yourself

    using the packet trace, verify that a DISCOVER always produces an OFFER (never NAK), and that a REQUEST always produces either ACK or NAK (never OFFER). what about a RELEASE?


    lab 6: ACL bypass — the priority 34000 flow

    this is one of those things that looks innocuous until you add a deny ACL and suddenly all your DHCP responses vanish. the priority 34000 flow is what keeps DHCP alive even when you’ve locked down everything else.

    set up topology with DHCP

    Terminal window
    # Use existing topology
    ovn-nbctl ls-list

    verify the ACL bypass flow exists

    Terminal window
    # Show the priority 34000 flow
    ovn-sbctl lflow-list sw0 | grep -A 10 "34000"

    what you’ll see: the flow matches DHCP replies from the server and allows them through:

    match: "outport == \"vm1\" && eth.src == 00:00:00:00:00:01 &&
    ip4.src == 10.0.0.1 && udp && udp.src == 67 && udp.dst == 68"
    action: "reg1[0] = 1; next;"

    add a drop ACL that would block DHCP

    Terminal window
    # Add a default deny ACL on egress (to-lport)
    # This would normally block DHCP replies from reaching vm1
    ovn-nbctl acl-add sw0 to-lport 1001 "ip4" drop
    # Verify the ACL exists
    ovn-nbctl acl-list sw0

    verify the bypass flow still exists

    Terminal window
    # The 34000 flow should still be there — it's generated by northd, not by user ACLs
    ovn-sbctl lflow-list sw0 | grep -A 10 "34000"

    what you’ll see: the flow still exists at priority 34000, which is higher than the user ACL’s logical flow priority (1001 + 1000 = 2001). the DHCP reply matches the 34000 flow first and gets reg1[0] = 1 (allow).

    trace a DHCP packet through the ACL pipeline

    Terminal window
    # Trace a DHCP reply coming from the server side
    # This simulates the packet after pinctrl constructs it
    ovn-trace sw0 'inport == "vm1" && eth.src == 00:00:00:00:00:01 && eth.dst == 00:00:00:00:00:01 && ip4.src == 10.0.0.1 && ip4.dst == 10.0.0.2 && udp.src == 67 && udp.dst == 68'

    what you’ll see: in the trace, look for the ls_out_acl_eval stage. the packet should match the 34000 flow and get reg1[0] = 1 (allow), bypassing the drop ACL.

    understand the flow priority hierarchy

    Priority 65535+ — Universal flows (highest)
    Priority 34000 — DHCP ACL bypass (northd-generated)
    Priority 2001+ — User-defined ACLs (priority + 1000)
    Priority 0 — Default passthrough (lowest)

    the 34000 priority is chosen to be:

    • higher than most user-defined ACLs (which typically use priorities 1-10000)
    • lower than universal flows at 65532+

    remove the ACL and verify

    Terminal window
    ovn-nbctl acl-del sw0 to-lport 1001 "ip4"

    try it yourself

    add multiple ACLs with different priorities (100, 1000, 10000, 30000) that would all block DHCP. verify that the 34000 bypass flow always takes precedence for DHCP traffic. at what user-defined ACL priority would you start blocking DHCP replies?


    lab 7: DHCP relay — full relay flow

    relay is where things get spicy. you’ve got a client on one subnet, a DHCP server somewhere else on the network, and a router in between doing packet surgery. the relay pipeline has to validate the request, rewrite headers, forward it to the server, then do the reverse on the way back. it’s a lot of moving parts.

    set up topology with relay

    Terminal window
    # Create two switches and a router
    ovn-nbctl ls-del sw0 2>/dev/null
    ovn-nbctl ls-del sw1 2>/dev/null
    ovn-nbctl lr-del R1 2>/dev/null
    ovn-nbctl ls-add sw0
    ovn-nbctl ls-add sw1
    ovn-nbctl lr-add R1
    # Client port on sw0
    ovn-nbctl lsp-add sw0 vm1
    ovn-nbctl lsp-set-type vm1 ethernet
    ovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"
    # Router ports
    ovn-nbctl lrp-add R1 sw0-p 00:00:00:00:ff:01 10.0.0.1/24
    ovn-nbctl lrp-add R1 sw1-p 00:00:00:00:ff:02 10.0.1.1/24
    # Connect switches to router
    ovn-nbctl lsp-add sw0 sw0-r
    ovn-nbctl lsp-set-type sw0-r router
    ovn-nbctl lsp-set-addresses sw0-r 00:00:00:00:ff:01
    ovn-nbctl lsp-set-options sw0-r router-port=sw0-p
    ovn-nbctl lsp-add sw1 sw1-r
    ovn-nbctl lsp-set-type sw1-r router
    ovn-nbctl lsp-set-addresses sw1-r 00:00:00:00:ff:02
    ovn-nbctl lsp-set-options sw1-r router-port=sw1-p

    create DHCP relay configuration

    Terminal window
    # Create DHCP relay pointing to remote server at 192.168.1.100
    RELAY_UUID=$(ovn-nbctl dhcp-relay-create dhcp-relay-1 192.168.1.100 | tr -d ' ')
    echo "RELAY_UUID=$RELAY_UUID"
    # Bind relay to router port
    ovn-nbctl lrp-set-dhcp-relay sw0-p $RELAY_UUID
    # Set the relay port on the switch
    ovn-nbctl set logical_switch sw0 other_config:dhcp_relay_port=sw0-r

    verify relay configuration

    Terminal window
    # List DHCP relay records
    ovn-nbctl dhcp-relay-list
    # Verify relay is bound to router port
    ovn-nbctl list logical_router_port sw0-p | grep -A 3 dhcp_relay
    # Verify relay port is set on switch
    ovn-nbctl list logical_switch sw0 | grep -A 3 other_config

    inspect relay flows on logical switch

    Terminal window
    # The switch generates a flow at ls_in_l2_lkup (stage 14) that redirects
    # DHCP requests to the router port
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_l2_lkup" | grep -A 5 "100"

    what you’ll see: a flow at priority 100 in ls_in_l2_lkup:

    match: "inport == \"vm1\" && eth.src == \"00:00:00:00:00:01\" &&
    ip4.src == {0.0.0.0, 10.0.0.0/24} && ip4.dst == 255.255.255.255 &&
    udp.src == 68 && udp.dst == 67"
    action: "eth.dst = 00:00:00:00:ff:01; outport = \"sw0-r\"; next;"

    this redirects the DHCP request to the router port.

    inspect relay flows on logical router

    Terminal window
    # The router generates 8 flows across 3 stages
    ovn-sbctl lflow-list R1 | grep -i dhcp

    what you’ll see: eight flows for the relay:

    request path (client → server):

    1. lr_in_ip_input (priority 110) — validate relay request, execute dhcp_relay_req_chk
    2. lr_in_dhcp_relay_req (priority 100) — forward valid request (rewrite src/dst)
    3. lr_in_dhcp_relay_req (priority 1) — drop invalid request

    response path (server → client): 4. lr_in_ip_input (priority 110) — match non-fragmented response 5. lr_in_dhcp_relay_resp_chk (priority 100) — validate relay response 6. lr_in_dhcp_relay_resp (priority 100) — deliver valid response 7. lr_in_dhcp_relay_resp (priority 1) — drop invalid response

    default passthrough: 8. priority 0 default flows for each relay stage

    trace the relay request path

    Terminal window
    # Simulate a DHCPDISCOVER from vm1
    ovn-trace sw0 'inport == "vm1" && eth.src == 00:00:00:00:00:01 && eth.dst == ff:ff:ff:ff:ff:ff && ip4.src == 0.0.0.0 && ip4.dst == 255.255.255.255 && udp.src == 68 && udp.dst == 67'

    what you’ll see: the trace shows:

    1. ls_in_l2_lkup — redirects to router port:

      action: eth.dst = 00:00:00:00:ff:01; outport = "sw0-r"; next;
    2. lr_in_ip_input — validates relay request:

      match: inport == "sw0-p" && ip4.src == {0.0.0.0, 10.0.0.0/24} &&
      ip4.dst == 255.255.255.255 && ip.frag == 0 &&
      udp.src == 68 && udp.dst == 67
      action: reg9[7] = dhcp_relay_req_chk(10.0.0.1, 192.168.1.100); next;
    3. lr_in_dhcp_relay_req — forwards valid request:

      match: ... && reg9[7]
      action: ip4.src = 10.0.0.1; ip4.dst = 192.168.1.100; udp.src = 67; next;

    understand relay validation (req_chk)

    the pinctrl_handle_dhcp_relay_req_chk() function validates:

    1. op == DHCP_OP_REQUEST — must be a request
    2. giaddr == 0 — relay agent IP must not already be set
    3. message type must be present in DHCP options
    4. falls back to ciaddr if option 50 not present

    then modifies the packet:

    • increments hops by 1
    • sets giaddr = relay_ip (10.0.0.1)
    • updates UDP checksum incrementally

    trace the relay response path

    Terminal window
    # Simulate a DHCPOFFER from the remote server back to the relay
    # The server sends: src=192.168.1.100, dst=10.0.0.1, udp 67→67
    # yiaddr=10.0.0.2, giaddr=10.0.0.1, chaddr=00:00:00:00:00:01
    ovn-trace R1 'inport == "sw1-p" && eth.src == 00:00:00:00:aa:aa && eth.dst == 00:00:00:00:ff:02 && ip4.src == 192.168.1.100 && ip4.dst == 10.0.0.1 && udp.src == 67 && udp.dst == 67'

    what you’ll see: the trace shows:

    1. lr_in_ip_input — matches response:

      match: ip4.src == 192.168.1.100 && ip4.dst == 10.0.0.1 &&
      ip.frag == 0 && udp.src == 67 && udp.dst == 67
      action: next;
    2. lr_in_dhcp_relay_resp_chk — validates response:

      action: reg2 = ip4.dst;
      reg9[8] = dhcp_relay_resp_chk(10.0.0.1, 192.168.1.100); next;
    3. lr_in_dhcp_relay_resp — delivers valid response:

      match: ... && reg9[8]
      action: ip4.src = 10.0.0.1; udp.dst = 68;
      outport = "sw0-p"; output;

    understand relay response validation (resp_chk)

    the pinctrl_handle_dhcp_relay_resp_chk() function performs 10 security checks:

    1. op == DHCP_OP_REPLY — must be a reply
    2. giaddr != 0 — must have been relayed
    3. giaddr == relay_ip — giaddr must match configured relay
    4. message type present
    5. server_id (option 54) present
    6. server_id == server_ip — must match configured server
    7. for OFFER/ACK: (yiaddr & netmask) == (giaddr & netmask) — allocated IP in same subnet
    8. for OFFER/ACK: warns if router_ip != giaddr
    9. sets eth.dst = client_mac (from chaddr)
    10. handles broadcast flag for ip_dst

    relay validation is thorough to say the least

    try it yourself

    what happens if the remote DHCP server responds with a server_id that doesn’t match the configured relay server? verify the validation check by inspecting the flow at lr_in_dhcp_relay_resp.


    lab 8: DHCPv6 — SOLICIT → ADVERT

    v6 works similarly to v4 but the message names are different and the option encoding uses 16-bit code/length fields. also, DHCPv6 only has 7 built-in options compared to v4’s 39 — because a lot of what v4 handles via DHCP options, v6 handles with RA and SLAAC.

    set up DHCPv6 options

    Terminal window
    # Create a port with DHCPv6 options
    ovn-nbctl lsp-add sw0 vm1-v6
    ovn-nbctl lsp-set-type vm1-v6 ethernet
    ovn-nbctl lsp-set-addresses vm1-v6 "00:00:00:00:00:01 10.0.0.2"
    # Create DHCPv6 options
    DHCPV6_UUID=$(ovn-nbctl dhcp-options-create fd00::/64 | tr -d ' ')
    # Set mandatory options
    ovn-nbctl set DHCP_Options $DHCPV6_UUID option:server_id=00:00:00:00:00:01
    ovn-nbctl set DHCP_Options $DHCPV6_UUID option:ia_addr=fd00::2
    ovn-nbctl set DHCP_Options $DHCPV6_UUID option:dns_server=fd00::53
    # Bind to port
    ovn-nbctl lsp-set-dhcpv6-options vm1-v6 $DHCPV6_UUID

    verify DHCPv6 logical flows

    Terminal window
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_dhcp_options" | grep -A 5 "100" | grep -i ipv6

    what you’ll see: the match for DHCPv6 uses multicast destination:

    match: "inport == \"vm1-v6\" && eth.src == \"00:00:00:00:00:01\" &&
    ip6.mcast && ip6.dst == ff02::1:2 &&
    udp.src == 546 && udp.dst == 547"
    action: "reg0[3] = put_dhcpv6_opts(ia_addr = fd00::2, server_id = 00:00:00:00:00:01, ...); next;"

    key differences from DHCPv4:

    • uses ff02::1:2 (all DHCP servers/relays multicast) as destination
    • UDP ports 546 (client) / 547 (server)
    • uses put_dhcpv6_opts instead of put_dhcp_opts

    trace a DHCPv6 SOLICIT

    Terminal window
    ovn-trace sw0 'inport == "vm1-v6" && eth.src == 00:00:00:00:00:01 && eth.dst == 33:33:00:01:00:02 && ip6.dst == ff02::1:2 && udp.src == 546 && udp.dst == 547'

    what you’ll see: similar pipeline to DHCPv4 but for v6. the handler maps SOLICIT → ADVERT.

    understand DHCPv6 option encoding

    DHCPv6 options use 16-bit code and length fields (vs 8-bit for DHCPv4):

    ┌──────────┬──────────┬──────────────────┐
    │ Code (2B)│ Len (2B) │ Value (variable) │
    └──────────┴──────────┴──────────────────┘

    types:

    • ipv6: 16 bytes per value
    • mac: 6 bytes (ETH_ADDR_LEN)
    • str: Raw bytes
    • domain: FQDN encoded per RFC 3696

    verify DHCPv6 ACL bypass flow

    Terminal window
    # DHCPv6 also gets a priority 34000 bypass flow
    ovn-sbctl lflow-list sw0 | grep -A 10 "34000" | grep -i ipv6

    what you’ll see: the v6 bypass flow matches:

    match: "outport == \"vm1-v6\" && eth.src == 00:00:00:00:00:01 &&
    ip6.src == fd00::2 && udp && udp.src == 547 && udp.dst == 546"
    action: "reg1[0] = 1; next;"

    try it yourself

    DHCPv6 has 7 built-in options vs 39 for DHCPv4. why is v6 simpler? what functionality is handled by other mechanisms in DHCPv6 (like RA, SLAAC)?


    lab 9: control plane policing (CoPP) for DHCP

    CoPP is how you stop a rogue client (or a runaway workload) from flooding the control plane with DHCP requests and knocking out ovn-controller. you attach a meter to the DHCP flow, and excess packets get dropped before they ever hit pinctrl.

    create a CoPP profile

    Terminal window
    # Create a CoPP record
    COPP_UUID=$(ovn-nbctl copp-add 2>/dev/null | tr -d ' ')
    # If copp-add doesn't work, create manually:
    # First, check if there's an existing COPP
    ovn-nbctl list NB_Global | grep copp
    # Create meter for DHCP rate limiting
    ovn-nbctl meter-add dhcp-meter rate=100 unit=kbps
    # Add DHCP to CoPP
    ovn-nbctl copp-add $COPP_UUID dhcpv4-opts dhcp-meter

    attach CoPP to logical switch

    Terminal window
    # Attach the CoPP profile to the switch
    ovn-nbctl set logical_switch sw0 other_config:copp=$COPP_UUID

    verify CoPP meter in logical flows

    Terminal window
    # The DHCP flow should now reference the meter
    ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_dhcp_options" | grep -A 5 "100"

    what you’ll see: the flow now includes a meter reference:

    match: "inport == \"vm1\" && ..."
    action: "reg0[3] = put_dhcp_opts(...); next;"
    meter: dhcp-meter

    the WITH_CTRL_METER(copp_meter_get(COPP_DHCPV4_OPTS, ...)) macro attaches the meter.

    understand CoPP protocol mapping

    Terminal window
    # CoPP protocol names (from ovn/lib/copp.c):
    # dhcpv4-opts → COPP_DHCPV4_OPTS
    # dhcpv6-opts → COPP_DHCPV6_OPTS
    # dhcpv4-relay → COPP_DHCPV4_RELAY
    # arp → COPP_ARP
    # dns → COPP_DNS

    verify meter exists in OVS

    Terminal window
    # On the compute node, check the meter
    ssh root@192.168.100.30 'ovs-ofctl dump-meters br-int 2>/dev/null'

    test rate limiting

    Terminal window
    # Set a very low rate to test
    ovn-nbctl meter-modify dhcp-meter rate=1 unit=kbps
    # Generate multiple DHCP requests (via trace or from a VM)
    # The meter should drop excess packets
    # Check meter stats
    ssh root@192.168.100.30 'ovs-ofctl dump-meter-stats br-int 2>/dev/null'

    try it yourself

    what happens if you set the meter rate to 0? does it block all DHCP traffic? what about setting a very high rate (e.g., 1000000)?


    lab 10: OVS in-band control and edge cases

    a grab bag of edge cases that are easy to overlook. the in-band DHCP rule is one of those things you don’t appreciate until it’s not there and your management port can’t get an IP anymore.

    OVS in-band DHCP check

    Terminal window
    # OVS ensures DHCP replies from the local management port aren't blocked
    # The function in_band_must_output_to_local_port() checks:
    # dl_type == ETH_TYPE_IP
    # nw_proto == IPPROTO_UDP
    # tp_src == DHCP_SERVER_PORT (67)
    # tp_dst == DHCP_CLIENT_PORT (68)
    # Verify the in-band rule exists on the compute node
    ssh root@192.168.100.30 'ovs-ofctl dump-flows br-int cookie=0/1 table=0 2>/dev/null | grep -i dhcp'

    the rule has priority 180000 (IBR_FROM_LOCAL_DHCP) and matches:

    match: in_port=LOCAL, dl_type=0x0800, dl_src=<local_mac>, nw_proto=17, tp_src=68, tp_dst=67

    test missing mandatory options

    this is the one that’ll get you if you’re not paying attention. create DHCP options without the mandatory three and… nothing happens. no errors, no warnings. just no flows.

    Terminal window
    # Create DHCP options WITHOUT mandatory options
    BROKEN_UUID=$(ovn-nbctl dhcp-options-create 10.0.0.0/24 | tr -d ' ')
    # Add only some optional options (no server_id, server_mac, or lease_time)
    ovn-nbctl set DHCP_Options $BROKEN_UUID option:netmask=255.255.255.0
    ovn-nbctl set DHCP_Options $BROKEN_UUID option:router=10.0.0.1
    # Bind to a port
    ovn-nbctl lsp-add sw0 vm-broken
    ovn-nbctl lsp-set-type vm-broken ethernet
    ovn-nbctl lsp-set-addresses vm-broken "00:00:00:00:00:99 10.0.0.99"
    ovn-nbctl lsp-set-dhcpv4-options vm-broken $BROKEN_UUID
    # Verify: NO DHCP flows should be generated for this port
    ovn-sbctl lflow-list sw0 | grep "vm-broken" | grep dhcp
    # Expected: empty
    # Now add server_id and server_mac (but not lease_time)
    ovn-nbctl set DHCP_Options $BROKEN_UUID option:server_id=10.0.0.1
    ovn-nbctl set DHCP_Options $BROKEN_UUID option:server_mac=00:00:00:00:00:01
    # Still no DHCP flows (lease_time is still missing)
    sleep 2
    ovn-sbctl lflow-list sw0 | grep "vm-broken" | grep dhcp
    # Expected: empty
    # Add lease_time
    ovn-nbctl set DHCP_Options $BROKEN_UUID option:lease_time=3600
    # NOW DHCP flows should appear
    sleep 2
    ovn-sbctl lflow-list sw0 | grep "vm-broken" | grep dhcp
    # Expected: DHCP flows present

    when you finally figure out why your DHCP flows aren't showing up

    test external ports

    Terminal window
    # External ports with DHCP options need both a localnet port and HA chassis group
    # Without them, no DHCP flows are generated
    ovn-nbctl lsp-add sw0 vm-external
    ovn-nbctl lsp-set-type vm-external external
    ovn-nbctl lsp-set-addresses vm-external "00:00:00:00:00:88 10.0.0.88"
    ovn-nbctl lsp-set-dhcpv4-options vm-external $DHCP_UUID
    # Verify: no DHCP flows for external port without localnet + HA
    ovn-sbctl lflow-list sw0 | grep "vm-external" | grep dhcp
    # Expected: empty

    test DHCP options without a port

    Terminal window
    # Create DHCP options but don't bind to any port
    ORPHAN_UUID=$(ovn-nbctl dhcp-options-create 10.0.2.0/24 | tr -d ' ')
    ovn-nbctl set DHCP_Options $ORPHAN_UUID option:server_id=10.0.2.1
    ovn-nbctl set DHCP_Options $ORPHAN_UUID option:server_mac=00:00:00:00:00:02
    ovn-nbctl set DHCP_Options $ORPHAN_UUID option:lease_time=3600
    # Verify: no flows reference these orphan options
    ovn-sbctl lflow-list sw0 | grep "10.0.2"
    # Expected: empty (no flows for unbound DHCP options)

    DHCPv6 prefix delegation

    Terminal window
    # DHCPv6 PD is handled by pinctrl_handle_dhcp6_server()
    # It processes Advertisements and Replies from DHCPv6 servers
    # for prefix delegation (IA_PD)
    # This is used when OVN acts as a DHCPv6 relay for PD
    # The handler extracts IA_PD options and constructs Request packets

    try it yourself

    create a switch with 5 ports, all with DHCP options. then create a switch with 5 ports where only 2 have DHCP options. compare the total flow counts. how many flows does each port with DHCP options add?


    cleanup

    run this after completing all labs:

    #!/bin/bash
    # Cleanup all lab resources
    # Remove all logical switches (this cascades to ports)
    for sw in sw0 sw1 sw0-test sw1-test; do
    ovn-nbctl ls-del "$sw" 2>/dev/null
    done
    # Remove all logical routers
    for lr in R1; do
    ovn-nbctl lr-del "$lr" 2>/dev/null
    done
    # Remove all DHCP options
    for uuid in $(ovn-nbctl dhcp-options-list | grep -o '[a-f0-9-]\{36\}'); do
    ovn-nbctl dhcp-options-del "$uuid" 2>/dev/null
    done
    # Remove all DHCP relay records
    for uuid in $(ovn-nbctl dhcp-relay-list 2>/dev/null | grep -o '[a-f0-9-]\{36\}'); do
    ovn-nbctl dhcp-relay-del "$uuid" 2>/dev/null
    done
    # Remove all meters
    for meter in $(ovn-nbctl meter-list 2>/dev/null | grep -o '[a-z-]*' | head -20); do
    ovn-nbctl meter-del "$meter" 2>/dev/null
    done
    # Remove all CoPP records
    # (manual cleanup may be needed)
    # Remove all ACLs
    for sw in $(ovn-nbctl ls-list | grep -o '[a-z0-9-]*'); do
    ovn-nbctl acl-del "$sw" 2>/dev/null
    done
    echo "Cleanup complete"
    ovn-nbctl ls-list
    ovn-nbctl lr-list
    ovn-nbctl dhcp-options-list

    how the labs map to the writeup

    LabWriteup SectionConcepts Covered
    1NB DatabaseDHCP_Options table, weak UUID refs, port binding
    2NB Database + NorthdOption types, wire format, SB sync
    3Northd Flow Generationls_in_dhcp_options/response, reg0[3], guards
    4Packet Walk (DISCOVER→OFFER→ACK)Full DORA, packet-in mechanism
    5Packet Walk (NAK, INFORM, RELEASE)Message type handling, NAK behavior
    6ACL BypassPriority 34000 flow, reg1[0]
    7DHCP RelayFull relay flow, relay validation, giaddr
    8DHCPv6SOLICIT→ADVERT, v6 option encoding
    9CoPPRate limiting, meter application
    10OVS In-Band + Edge CasesIn-band rule, missing options, external ports