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
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.2key pipeline stages (DHCP)
| Pipeline | Stage | Name | Purpose |
|---|---|---|---|
| LS Ingress | 28 | ls_in_dhcp_options | Match DHCP, encode put_dhcp_opts action |
| LS Ingress | 29 | ls_in_dhcp_response | If reg0[3]==1, rewrite L2/L3 and output |
| LR Ingress | 4 | lr_in_dhcp_relay_req | Rewrite src/dst, forward DHCP request to server |
| LR Ingress | 20 | lr_in_dhcp_relay_resp_chk | Validate relay response from server |
| LR Ingress | 21 | lr_in_dhcp_relay_resp | Rewrite headers, deliver response to client |
register bits
| Register | Name | Purpose |
|---|---|---|
reg0[3] | REGBIT_DHCP_OPTS_RESULT | Set to 1 by put_dhcp_opts if encoding succeeds |
reg9[7] | REGBIT_DHCP_RELAY_REQ_CHK | Set by dhcp_relay_req_chk if relay request valid |
reg9[8] | REGBIT_DHCP_RELAY_RESP_CHK | Set by dhcp_relay_resp_chk if relay response valid |
reg2 | REG_DHCP_RELAY_DIP_IPV4 | Stores 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.
| Option | Code | Purpose |
|---|---|---|
server_id | 54 | DHCP server identifier (offer IP) |
server_mac | — | Server MAC address for L2 response |
lease_time | 51 | IP address lease duration |
command reference
# DHCP optionsovn-nbctl dhcp-options-create <cidr>ovn-nbctl dhcp-options-listovn-nbctl dhcp-options-del <uuid>ovn-nbctl set DHCP_Options <uuid> option:<key>=<value>ovn-nbctl get DHCP_Options <uuid> option
# Port DHCP bindingovn-nbctl lsp-set-dhcpv4-options <port> <uuid>ovn-nbctl lsp-get-dhcpv4-options <port>
# DHCP relayovn-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 flowsovn-sbctl lflow-list <datapath>ovn-sbctl lflow-list <datapath> | grep dhcp
# Packet traceovn-trace <switch> '<match>'
# OpenFlow inspection (on compute node)ovs-ofctl dump-flows br-intovs-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)'
# CoPPovn-nbctl copp-add <copp-uuid> dhcpv4-opts <meter-name>ovn-nbctl meter-add <meter-name> rate=<n> unit=kbpslab 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
# On ovn-central-01ovn-nbctl ls-add sw0ovn-nbctl lsp-add sw0 vm1ovn-nbctl lsp-set-type vm1 ethernetovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"create a DHCP options record
# Create DHCP options for 10.0.0.0/24 subnetovn-nbctl dhcp-options-create 10.0.0.0/24# Returns UUID, e.g.: 0e4992ce-8d4e-4f72-a8d5-0c5b51ab1f3f
# Store the UUID for laterDHCP_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
# These three are REQUIRED — without them, no DHCP flows are generatedovn-nbctl set DHCP_Options $DHCP_UUID option:server_id=10.0.0.1ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600set optional options
ovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1ovn-nbctl set DHCP_Options $DHCP_UUID option:dns_server=8.8.8.8verify the DHCP options record
# List all DHCP optionsovn-nbctl dhcp-options-list# Expected: shows the UUID and CIDR
# Dump full recordovn-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 optionsovn-nbctl get DHCP_Options $DHCP_UUID optionbind DHCP options to port (weak UUID reference)
# Bind the DHCP options to vm1's portovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUID
# Verify the bindingovn-nbctl lsp-get-dhcpv4-options vm1# Expected: returns the UUID
# Full port listing shows the referenceovn-nbctl lsp-list sw0inspect the NB database schema
# Examine the DHCP_Options table schemaovn-nbctl list-table | grep -A 20 "DHCP_Options"
# Examine the weak UUID reference on the portovn-nbctl list logical_switch_port vm1 | grep -A 5 dhcpv4what 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
# Create a second port and bind the same DHCP optionsovn-nbctl lsp-add sw0 vm2ovn-nbctl lsp-set-type vm2 ethernetovn-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 optionsovn-nbctl lsp-get-dhcpv4-options vm1ovn-nbctl lsp-get-dhcpv4-options vm2# Both return the same UUID
# Delete the DHCP options recordovn-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 cascadeovn-nbctl lsp-list sw0try 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
ovn-nbctl ls-add sw0-testovn-nbctl lsp-add sw0-test vm1ovn-nbctl lsp-set-type vm1 ethernetovn-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 optionsovn-nbctl set DHCP_Options $DHCP_UUID option:server_id=10.0.0.1ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600
# Options of various typesovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0 # ipv4ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1 # ipv4ovn-nbctl set DHCP_Options $DHCP_UUID option:dns_server=8.8.8.8 # ipv4ovn-nbctl set DHCP_Options $DHCP_UUID option:mtu=1500 # uint16ovn-nbctl set DHCP_Options $DHCP_UUID option:default_ttl=64 # uint8ovn-nbctl set DHCP_Options $DHCP_UUID option:hostname=vm1.example.com # strovn-nbctl set DHCP_Options $DHCP_UUID option:domain_name=example.com # strovn-nbctl set DHCP_Options $DHCP_UUID option:ip_forward_enable=true # boolovn-nbctl set DHCP_Options $DHCP_UUID option:broadcast_address=10.0.0.255 # ipv4
# Bind to portovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUIDverify option codes via SB database
# The NB stores option names; northd syncs them to SB with codes# Check the SB DHCP options tableovn-sbctl list DHCP_Options | grep -E "name|code|type"what you’ll see: the SB database has entries like:
name : "netmask"code : 1type : "ipv4"
name : "router"code : 3type : "ipv4"
name : "dns_server"code : 6type : "ipv4"
name : "mtu"code : 26type : "uint16"
name : "default_ttl"code : 23type : "uint8"
name : "lease_time"code : 51type : "uint32"
name : "server_id"code : 54type : "ipv4"verify the 39 built-in DHCPv4 options
# Count the built-in optionsovn-sbctl list DHCP_Options | grep "name" | wc -l# Expected: 39 (for DHCPv4)
# List all of themovn-sbctl list DHCP_Options | grep "name" | sortverify DHCPv6 built-in options
# Count the built-in v6 optionsovn-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, fqdnunderstand option type wire formats
each type has a distinct encoding in the put_dhcp_opts action:
| Type | Wire Format | Example |
|---|---|---|
ipv4 | 4 bytes, network byte order | 10.0.0.1 → 0x0a000001 |
uint8 | 1 byte | 64 → 0x40 |
uint16 | 2 bytes, network byte order | 1500 → 0x05dc |
uint32 | 4 bytes, network byte order | 3600 → 0x00000e10 |
bool | 1 byte (0 or 1) | true → 0x01 |
str | Raw bytes, length = strlen | "hello" → 0x68656c6c6f |
verify by inspecting the logical flow action string:
# Look at the action for vm1's portovn-sbctl lflow-list sw0-test | grep -A 5 "ls_in_dhcp_options" | head -20what 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
# Clean slateovn-nbctl ls-del sw0-test 2>/dev/null
ovn-nbctl ls-add sw0ovn-nbctl lsp-add sw0 vm1ovn-nbctl lsp-set-type vm1 ethernetovn-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.1ovn-nbctl set DHCP_Options $DHCP_UUID option:server_mac=00:00:00:00:00:01ovn-nbctl set DHCP_Options $DHCP_UUID option:lease_time=3600ovn-nbctl set DHCP_Options $DHCP_UUID option:netmask=255.255.255.0ovn-nbctl set DHCP_Options $DHCP_UUID option:router=10.0.0.1ovn-nbctl lsp-set-dhcpv4-options vm1 $DHCP_UUIDinspect DHCP logical flows
# Show all DHCP-related flowsovn-sbctl lflow-list sw0 | grep -i dhcpwhat you’ll see: three flows per port for DHCPv4:
- flow at
ls_in_dhcp_options(priority 100) — matches DHCP request, executesput_dhcp_opts - flow at
ls_in_dhcp_response(priority 100) — ifreg0[3]==1, rewrites headers and outputs - 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
# Show the specific flow at ls_in_dhcp_optionsovn-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 requestsreg0[3]is set to 1 ifput_dhcp_optssucceeds, 0 otherwise- the action includes
next;to continue the pipeline
examine flow 2: DHCP response generation
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)
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)
# Show default flows at DHCP stagesovn-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:
- port must be enabled and not a router port
- port must have
dhcpv4_optionsordhcpv6_optionsconfigured - DHCP relay must NOT be enabled on this switch
# Verify: without DHCP options, no flows are generatedovn-nbctl lsp-add sw0 vm-nodhcpovn-nbctl lsp-set-type vm-nodhcp ethernetovn-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 flowsovn-sbctl lflow-list sw0 | grep "vm-nodhcp" | grep dhcp# Expected: empty (no DHCP flows for this port)verify flow count changes
# Count total flows before and after adding DHCP optionsBEFORE=$(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
# Use the existing topology from Lab 1ovn-nbctl ls-list # Verify sw0 existsovn-nbctl lsp-get-dhcpv4-options vm1 # Verify DHCP options are boundtrace a DHCPDISCOVER
# 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:
-
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 == 67action: reg0[3] = put_dhcp_opts(offerip = 10.0.0.2, ...); next; -
ls_in_dhcp_response(stage 29) — matches at priority 100 becausereg0[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)
# 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_REQUEST→msg_type = DHCP_MSG_ACKdhcp_opts.request_ip == *offer_ip→10.0.0.2 == 10.0.0.2→ ACK
trace a directed DHCPREQUEST (renewal)
# 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}— matches10.0.0.2(the renewal case)ip4.dst == {10.0.0.1, 255.255.255.255}— matches10.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.
# On ovn-compute, inspect the OpenFlow rules that handle the packet-inssh 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:
- parses the userdata
- validates the DHCP packet (htype=0x1, hlen=0x6, magic cookie=0x63825363)
- parses options (extracts msg_type, request_ip, server_id)
- determines response type (DISCOVER→OFFER, REQUEST→ACK/NAK)
- constructs the reply packet
- writes success/failure to
reg0[3]and resumes the pipeline
try it yourself
capture the actual DHCP exchange using tcpdump on the compute node:
# On ovn-computessh 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
# 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 IPovn-nbctl get DHCP_Options $DHCP_UUID optionwhat 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)
# Verify this by looking at the pinctrl handler# On ovn-compute, check logs for NAK activityssh root@192.168.100.30 'grep -i nak /var/log/openvswitch/ovn-controller.log 2>/dev/null | tail -5'trace a DHCPINFORM
# 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
# A DHCPRELEASE is sent by a client to release its IP# msg_type=RELEASE(7)
# The handler just logs it — no response is sentwhat happens in pinctrl:
case OVN_DHCP_MSG_RELEASE: /* Just log it */ break;verify OVN DHCP message types
# 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 = 8try 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
# Use existing topologyovn-nbctl ls-listverify the ACL bypass flow exists
# Show the priority 34000 flowovn-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
# Add a default deny ACL on egress (to-lport)# This would normally block DHCP replies from reaching vm1ovn-nbctl acl-add sw0 to-lport 1001 "ip4" drop
# Verify the ACL existsovn-nbctl acl-list sw0verify the bypass flow still exists
# The 34000 flow should still be there — it's generated by northd, not by user ACLsovn-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
# Trace a DHCP reply coming from the server side# This simulates the packet after pinctrl constructs itovn-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
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
# Create two switches and a routerovn-nbctl ls-del sw0 2>/dev/nullovn-nbctl ls-del sw1 2>/dev/nullovn-nbctl lr-del R1 2>/dev/null
ovn-nbctl ls-add sw0ovn-nbctl ls-add sw1ovn-nbctl lr-add R1
# Client port on sw0ovn-nbctl lsp-add sw0 vm1ovn-nbctl lsp-set-type vm1 ethernetovn-nbctl lsp-set-addresses vm1 "00:00:00:00:00:01 10.0.0.2"
# Router portsovn-nbctl lrp-add R1 sw0-p 00:00:00:00:ff:01 10.0.0.1/24ovn-nbctl lrp-add R1 sw1-p 00:00:00:00:ff:02 10.0.1.1/24
# Connect switches to routerovn-nbctl lsp-add sw0 sw0-rovn-nbctl lsp-set-type sw0-r routerovn-nbctl lsp-set-addresses sw0-r 00:00:00:00:ff:01ovn-nbctl lsp-set-options sw0-r router-port=sw0-p
ovn-nbctl lsp-add sw1 sw1-rovn-nbctl lsp-set-type sw1-r routerovn-nbctl lsp-set-addresses sw1-r 00:00:00:00:ff:02ovn-nbctl lsp-set-options sw1-r router-port=sw1-pcreate DHCP relay configuration
# Create DHCP relay pointing to remote server at 192.168.1.100RELAY_UUID=$(ovn-nbctl dhcp-relay-create dhcp-relay-1 192.168.1.100 | tr -d ' ')echo "RELAY_UUID=$RELAY_UUID"
# Bind relay to router portovn-nbctl lrp-set-dhcp-relay sw0-p $RELAY_UUID
# Set the relay port on the switchovn-nbctl set logical_switch sw0 other_config:dhcp_relay_port=sw0-rverify relay configuration
# List DHCP relay recordsovn-nbctl dhcp-relay-list
# Verify relay is bound to router portovn-nbctl list logical_router_port sw0-p | grep -A 3 dhcp_relay
# Verify relay port is set on switchovn-nbctl list logical_switch sw0 | grep -A 3 other_configinspect relay flows on logical switch
# The switch generates a flow at ls_in_l2_lkup (stage 14) that redirects# DHCP requests to the router portovn-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
# The router generates 8 flows across 3 stagesovn-sbctl lflow-list R1 | grep -i dhcpwhat you’ll see: eight flows for the relay:
request path (client → server):
lr_in_ip_input(priority 110) — validate relay request, executedhcp_relay_req_chklr_in_dhcp_relay_req(priority 100) — forward valid request (rewrite src/dst)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
# Simulate a DHCPDISCOVER from vm1ovn-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:
-
ls_in_l2_lkup— redirects to router port:action: eth.dst = 00:00:00:00:ff:01; outport = "sw0-r"; next; -
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 == 67action: reg9[7] = dhcp_relay_req_chk(10.0.0.1, 192.168.1.100); next; -
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:
op == DHCP_OP_REQUEST— must be a requestgiaddr == 0— relay agent IP must not already be set- message type must be present in DHCP options
- falls back to
ciaddrif option 50 not present
then modifies the packet:
- increments
hopsby 1 - sets
giaddr = relay_ip(10.0.0.1) - updates UDP checksum incrementally
trace the relay response path
# 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:
-
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 == 67action: next; -
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; -
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:
op == DHCP_OP_REPLY— must be a replygiaddr != 0— must have been relayedgiaddr == relay_ip— giaddr must match configured relay- message type present
- server_id (option 54) present
server_id == server_ip— must match configured server- for OFFER/ACK:
(yiaddr & netmask) == (giaddr & netmask)— allocated IP in same subnet - for OFFER/ACK: warns if
router_ip != giaddr - sets
eth.dst = client_mac(from chaddr) - handles broadcast flag for ip_dst

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
# Create a port with DHCPv6 optionsovn-nbctl lsp-add sw0 vm1-v6ovn-nbctl lsp-set-type vm1-v6 ethernetovn-nbctl lsp-set-addresses vm1-v6 "00:00:00:00:00:01 10.0.0.2"
# Create DHCPv6 optionsDHCPV6_UUID=$(ovn-nbctl dhcp-options-create fd00::/64 | tr -d ' ')
# Set mandatory optionsovn-nbctl set DHCP_Options $DHCPV6_UUID option:server_id=00:00:00:00:00:01ovn-nbctl set DHCP_Options $DHCPV6_UUID option:ia_addr=fd00::2ovn-nbctl set DHCP_Options $DHCPV6_UUID option:dns_server=fd00::53
# Bind to portovn-nbctl lsp-set-dhcpv6-options vm1-v6 $DHCPV6_UUIDverify DHCPv6 logical flows
ovn-sbctl lflow-list sw0 | grep -A 10 "ls_in_dhcp_options" | grep -A 5 "100" | grep -i ipv6what 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_optsinstead ofput_dhcp_opts
trace a DHCPv6 SOLICIT
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 valuemac: 6 bytes (ETH_ADDR_LEN)str: Raw bytesdomain: FQDN encoded per RFC 3696
verify DHCPv6 ACL bypass flow
# DHCPv6 also gets a priority 34000 bypass flowovn-sbctl lflow-list sw0 | grep -A 10 "34000" | grep -i ipv6what 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
# Create a CoPP recordCOPP_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 COPPovn-nbctl list NB_Global | grep copp
# Create meter for DHCP rate limitingovn-nbctl meter-add dhcp-meter rate=100 unit=kbps
# Add DHCP to CoPPovn-nbctl copp-add $COPP_UUID dhcpv4-opts dhcp-meterattach CoPP to logical switch
# Attach the CoPP profile to the switchovn-nbctl set logical_switch sw0 other_config:copp=$COPP_UUIDverify CoPP meter in logical flows
# The DHCP flow should now reference the meterovn-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-meterthe WITH_CTRL_METER(copp_meter_get(COPP_DHCPV4_OPTS, ...)) macro attaches the meter.
understand CoPP protocol mapping
# 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_DNSverify meter exists in OVS
# On the compute node, check the meterssh root@192.168.100.30 'ovs-ofctl dump-meters br-int 2>/dev/null'test rate limiting
# Set a very low rate to testovn-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 statsssh 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
# 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 nodessh 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=67test 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.
# Create DHCP options WITHOUT mandatory optionsBROKEN_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.0ovn-nbctl set DHCP_Options $BROKEN_UUID option:router=10.0.0.1
# Bind to a portovn-nbctl lsp-add sw0 vm-brokenovn-nbctl lsp-set-type vm-broken ethernetovn-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 portovn-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.1ovn-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 2ovn-sbctl lflow-list sw0 | grep "vm-broken" | grep dhcp# Expected: empty
# Add lease_timeovn-nbctl set DHCP_Options $BROKEN_UUID option:lease_time=3600
# NOW DHCP flows should appearsleep 2ovn-sbctl lflow-list sw0 | grep "vm-broken" | grep dhcp# Expected: DHCP flows present
test external ports
# 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-externalovn-nbctl lsp-set-type vm-external externalovn-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 + HAovn-sbctl lflow-list sw0 | grep "vm-external" | grep dhcp# Expected: emptytest DHCP options without a port
# Create DHCP options but don't bind to any portORPHAN_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.1ovn-nbctl set DHCP_Options $ORPHAN_UUID option:server_mac=00:00:00:00:00:02ovn-nbctl set DHCP_Options $ORPHAN_UUID option:lease_time=3600
# Verify: no flows reference these orphan optionsovn-sbctl lflow-list sw0 | grep "10.0.2"# Expected: empty (no flows for unbound DHCP options)DHCPv6 prefix delegation
# 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 packetstry 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/nulldone
# Remove all logical routersfor lr in R1; do ovn-nbctl lr-del "$lr" 2>/dev/nulldone
# Remove all DHCP optionsfor uuid in $(ovn-nbctl dhcp-options-list | grep -o '[a-f0-9-]\{36\}'); do ovn-nbctl dhcp-options-del "$uuid" 2>/dev/nulldone
# Remove all DHCP relay recordsfor 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/nulldone
# Remove all metersfor meter in $(ovn-nbctl meter-list 2>/dev/null | grep -o '[a-z-]*' | head -20); do ovn-nbctl meter-del "$meter" 2>/dev/nulldone
# Remove all CoPP records# (manual cleanup may be needed)
# Remove all ACLsfor sw in $(ovn-nbctl ls-list | grep -o '[a-z0-9-]*'); do ovn-nbctl acl-del "$sw" 2>/dev/nulldone
echo "Cleanup complete"ovn-nbctl ls-listovn-nbctl lr-listovn-nbctl dhcp-options-listhow the labs map to the writeup
| Lab | Writeup Section | Concepts Covered |
|---|---|---|
| 1 | NB Database | DHCP_Options table, weak UUID refs, port binding |
| 2 | NB Database + Northd | Option types, wire format, SB sync |
| 3 | Northd Flow Generation | ls_in_dhcp_options/response, reg0[3], guards |
| 4 | Packet Walk (DISCOVER→OFFER→ACK) | Full DORA, packet-in mechanism |
| 5 | Packet Walk (NAK, INFORM, RELEASE) | Message type handling, NAK behavior |
| 6 | ACL Bypass | Priority 34000 flow, reg1[0] |
| 7 | DHCP Relay | Full relay flow, relay validation, giaddr |
| 8 | DHCPv6 | SOLICIT→ADVERT, v6 option encoding |
| 9 | CoPP | Rate limiting, meter application |
| 10 | OVS In-Band + Edge Cases | In-band rule, missing options, external ports |