Logo

Search

    Overview
    OVN/OVS DHCP Internals: From `ovn-nbctl set-lsp` to the DHCP Response Packet

    OVN/OVS DHCP Internals: From `ovn-nbctl set-lsp` to the DHCP Response Packet

    July 15, 2026
    41 min read

    the setup

    when you run a command like:

    Terminal window
    ovn-nbctl lsp-set-dhcpv4-options sw0-port0 dhcpv4opts

    you’re telling OVN: “this logical port should have native DHCPv4 service, using the options defined in the dhcpv4opts DHCP_Options record.” the entire DHCP machinery — flow generation, packet construction, option encoding — kicks off from this single database reference.

    the DHCP implementation in OVN spans the full stack:

    1. NB Database stores the DHCP configuration (option values, CIDR, server parameters)
    2. Northd syncs option definitions to the SB database and generates logical flows
    3. OVN Controller reads option definitions and handles DHCP packet-Ins via pinctrl
    4. OVS In-Band ensures DHCP replies from the local management port aren’t blocked by flow tables

    i’m going to trace each layer. buckle up.


    the nb database

    DHCP options table

    the NB database schema defines the DHCP_Options table (ovn/ovn-nb.ovsschema:711-719):

    "DHCP_Options": {
    "columns": {
    "cidr": {"type": "string"},
    "options": {"type": {"key": "string", "value": "string",
    "min": 0, "max": "unlimited"}},
    "external_ids": {
    "type": {"key": "string", "value": "string",
    "min": 0, "max": "unlimited"}}},
    "isRoot": true},

    three columns:

    • cidr: The subnet CIDR that these options apply to (e.g., "10.0.0.0/24")
    • options: A string→string map of DHCP option name/value pairs (e.g., "server_id" → "10.0.0.1", "server_mac" → "00:00:00:00:00:01")
    • external_ids: User-defined metadata

    DHCP relay table

    for DHCP relay scenarios, the NB schema defines DHCP_Relay (ovn/ovn-nb.ovsschema:720-731):

    "DHCP_Relay": {
    "columns": {
    "name": {"type": "string"},
    "servers": {"type": {"key": "string", "min": 0, "max": 1}},
    "options": {"type": {"key": "string", "value": "string",
    "min": 0, "max": "unlimited"}},
    "external_ids": {
    "type": {"key": "string", "value": "string",
    "min": 0, "max": "unlimited"}}},
    "isRoot": true},

    the servers column holds a single string — the IP address (and optionally port) of the remote DHCP server to relay to.

    how ports reference DHCP configuration

    logical switch ports reference DHCP options via weak UUID references (ovn/ovn-nb.ovsschema:161-170):

    "dhcpv4_options": {"type": {"key": {"type": "uuid",
    "refTable": "DHCP_Options",
    "refType": "weak"},
    "min": 0, "max": 1}},
    "dhcpv6_options": {"type": {"key": {"type": "uuid",
    "refTable": "DHCP_Options",
    "refType": "weak"},
    "min": 0, "max": 1}},

    both are optional (min 0) and single-valued (max 1). they’re weak references — if the DHCP_Options record is deleted, the reference becomes NULL rather than cascading a delete.

    logical router ports reference DHCP relay via a strong UUID reference (ovn/ovn-nb.ovsschema:597-601):

    "dhcp_relay": {"type": {"key": {"type": "uuid",
    "refTable": "DHCP_Relay",
    "refType": "strong"},
    "min": 0, "max": 1}},

    and the logical switch itself has a runtime key dhcp_relay_port in its other_config map that identifies which switch port connects to the router performing relay:

    "other_config": {
    "type": {"key": "string", "value": "string", "min": 0, "max": "unlimited"}},

    this is set via ovn-nbctl set logical_switch <ls> other_config:dhcp_relay_port=<port>.


    northd: syncing dhcp options to sb

    the ovn-northd daemon maintains the SB database’s DHCP option definition tables. this ensures that every hypervisor knows the canonical set of DHCP options and their wire formats.

    the built-in option tables

    northd defines two static arrays of supported options in ovn/northd/ovn-northd.c:244-293:

    DHCPv4 options (supported_dhcp_opts[], 39 entries):

    static struct gen_opts_map supported_dhcp_opts[] = {
    OFFERIP, // "offerip", code 0, type "ipv4"
    DHCP_OPT_NETMASK, // "netmask", code 1, type "ipv4"
    DHCP_OPT_ROUTER, // "router", code 3, type "ipv4"
    DHCP_OPT_DNS_SERVER, // "dns_server", code 6, type "ipv4"
    DHCP_OPT_LOG_SERVER, // "log_server", code 7, type "ipv4"
    DHCP_OPT_LPR_SERVER, // "lpr_server", code 9, type "ipv4"
    DHCP_OPT_SWAP_SERVER, // "swap_server", code 16, type "ipv4"
    DHCP_OPT_POLICY_FILTER, // "policy_filter", code 21, type "ipv4"
    DHCP_OPT_ROUTER_SOLICITATION, // "router_solicitation", code 32, type "ipv4"
    DHCP_OPT_NIS_SERVER, // "nis_server", code 41, type "ipv4"
    DHCP_OPT_NTP_SERVER, // "ntp_server", code 42, type "ipv4"
    DHCP_OPT_SERVER_ID, // "server_id", code 54, type "ipv4"
    DHCP_OPT_TFTP_SERVER, // "tftp_server", code 66, type "host_id"
    DHCP_OPT_CLASSLESS_STATIC_ROUTE,// "classless_static_route", code 121, type "static_routes"
    DHCP_OPT_MS_CLASSLESS_STATIC_ROUTE, // "ms_classless_static_route", code 249, type "static_routes"
    DHCP_OPT_IP_FORWARD_ENABLE, // "ip_forward_enable", code 19, type "bool"
    DHCP_OPT_ROUTER_DISCOVERY, // "router_discovery", code 31, type "bool"
    DHCP_OPT_ETHERNET_ENCAP, // "ethernet_encap", code 36, type "bool"
    DHCP_OPT_DEFAULT_TTL, // "default_ttl", code 23, type "uint8"
    DHCP_OPT_TCP_TTL, // "tcp_ttl", code 37, type "uint8"
    DHCP_OPT_MTU, // "mtu", code 26, type "uint16"
    DHCP_OPT_LEASE_TIME, // "lease_time", code 51, type "uint32"
    DHCP_OPT_T1, // "T1", code 58, type "uint32"
    DHCP_OPT_T2, // "T2", code 59, type "uint32"
    DHCP_OPT_WPAD, // "wpad", code 252, type "str"
    DHCP_OPT_BOOTFILE, // "bootfile_name", code 67, type "str"
    DHCP_OPT_PATH_PREFIX, // "path_prefix", code 210, type "str"
    DHCP_OPT_TFTP_SERVER_ADDRESS, // "tftp_server_address", code 150, type "ipv4"
    DHCP_OPT_HOSTNAME, // "hostname", code 12, type "str"
    DHCP_OPT_DOMAIN_NAME, // "domain_name", code 15, type "str"
    DHCP_OPT_ARP_CACHE_TIMEOUT, // "arp_cache_timeout", code 35, type "uint32"
    DHCP_OPT_TCP_KEEPALIVE_INTERVAL,// "tcp_keepalive_interval", code 38, type "uint32"
    DHCP_OPT_DOMAIN_SEARCH_LIST, // "domain_search_list", code 119, type "domains"
    DHCP_OPT_BOOTFILE_ALT, // "bootfile_name_alt", code 254, type "str"
    DHCP_OPT_BROADCAST_ADDRESS, // "broadcast_address", code 28, type "ipv4"
    DHCP_OPT_NETBIOS_NAME_SERVER, // "netbios_name_server", code 44, type "ipv4"
    DHCP_OPT_NETBIOS_NODE_TYPE, // "netbios_node_type", code 46, type "uint8"
    DHCP_OPT_NEXT_SERVER, // "next_server", code 253, type "ipv4"
    };

    each entry is a struct gen_opts_map defined in ovn/lib/ovn-l7.h:57-62:

    struct gen_opts_map {
    struct hmap_node hmap_node;
    char *name;
    char *type;
    size_t code;
    };

    the macros like DHCP_OPT_NETMASK expand via the DHCP_OPTION macro (ovn/lib/ovn-l7.h:77-78):

    #define DHCP_OPTION(NAME, CODE, TYPE) \
    {.name = NAME, .code = CODE, .type = TYPE}
    #define DHCP_OPT_NETMASK DHCP_OPTION("netmask", 1, "ipv4")

    DHCPv6 options (supported_dhcpv6_opts[], 7 entries):

    static struct gen_opts_map supported_dhcpv6_opts[] = {
    DHCPV6_OPT_IA_ADDR, // "ia_addr", code 5, type "ipv6"
    DHCPV6_OPT_SERVER_ID, // "server_id", code 2, type "mac"
    DHCPV6_OPT_DOMAIN_SEARCH, // "domain_search", code 24, type "str"
    DHCPV6_OPT_DNS_SERVER, // "dns_server", code 23, type "ipv6"
    DHCPV6_OPT_BOOTFILE_NAME, // "bootfile_name", code 59, type "str"
    DHCPV6_OPT_BOOTFILE_NAME_ALT,// "bootfile_name_alt", code 254, type "str"
    DHCPV6_OPT_FQDN, // "fqdn", code 39, type "domain"
    };

    the sync function

    the function check_and_add_supported_dhcp_opts_to_sb_db() (ovn/northd/ovn-northd.c:448-485) performs a full reconciliation:

    static void
    check_and_add_supported_dhcp_opts_to_sb_db(struct ovsdb_idl_txn *ovnsb_txn,
    struct ovsdb_idl *ovnsb_idl)
    {
    struct hmap dhcp_opts_to_add = HMAP_INITIALIZER(&dhcp_opts_to_add);
    for (size_t i = 0; (i < sizeof(supported_dhcp_opts) /
    sizeof(supported_dhcp_opts[0])); i++) {
    hmap_insert(&dhcp_opts_to_add, &supported_dhcp_opts[i].hmap_node,
    dhcp_opt_hash(supported_dhcp_opts[i].name));
    }
    const struct sbrec_dhcp_options *opt_row;
    SBREC_DHCP_OPTIONS_FOR_EACH_SAFE (opt_row, ovnsb_idl) {
    struct gen_opts_map *dhcp_opt =
    dhcp_opts_find(&dhcp_opts_to_add, opt_row->name);
    if (dhcp_opt) {
    if (!strcmp(dhcp_opt->type, opt_row->type) &&
    dhcp_opt->code == opt_row->code) {
    hmap_remove(&dhcp_opts_to_add, &dhcp_opt->hmap_node);
    } else {
    sbrec_dhcp_options_delete(opt_row); /* Type/code mismatch → delete */
    }
    } else {
    sbrec_dhcp_options_delete(opt_row); /* Unknown option → delete */
    }
    }
    struct gen_opts_map *opt;
    HMAP_FOR_EACH (opt, hmap_node, &dhcp_opts_to_add) {
    struct sbrec_dhcp_options *sbrec_dhcp_option =
    sbrec_dhcp_options_insert(ovnsb_txn);
    sbrec_dhcp_options_set_name(sbrec_dhcp_option, opt->name);
    sbrec_dhcp_options_set_code(sbrec_dhcp_option, opt->code);
    sbrec_dhcp_options_set_type(sbrec_dhcp_option, opt->type);
    }
    hmap_destroy(&dhcp_opts_to_add);
    }

    the algorithm is straightforward:

    1. build an hmap of all built-in options
    2. walk existing SB rows — if an option matches (name + code + type), remove it from the “to add” map; if it mismatches or is unknown, delete it
    3. insert any remaining options from the “to add” map as new SB rows

    the SB schema for these tables (ovn/ovn-sb.ovsschema:305-328):

    "DHCP_Options": {
    "columns": {
    "name": {"type": "string"},
    "code": {"type": {"key": {"type": "integer",
    "minInteger": 0, "maxInteger": 254}}},
    "type": {"type": {"key": {"type": "string",
    "enum": ["set", ["bool", "uint8", "uint16", "uint32",
    "ipv4", "static_routes", "str",
    "host_id", "domains"]]}}}},
    "isRoot": true},
    "DHCPv6_Options": {
    "columns": {
    "name": {"type": "string"},
    "code": {"type": {"key": {"type": "integer",
    "minInteger": 0, "maxInteger": 254}}},
    "type": {"type": {"key": {"type": "string",
    "enum": ["set", ["ipv6", "str", "mac", "domain"]]}}}},
    "isRoot": true},

    northd: generating logical flows for dhcp

    this is where the real work happens. northd reads the DHCP configuration from the NB database and generates logical flows that will instruct ovn-controller how to handle DHCP packets.

    pipeline stage architecture

    DHCP-related pipeline stages are defined in ovn/northd/northd.h:548-605:

    Logical Switch Ingress Pipeline:

    StageNamePurpose
    28ls_in_dhcp_optionsMatch DHCP packets, encode put_dhcp_opts action
    29ls_in_dhcp_responseIf put_dhcp_opts succeeded, rewrite L2/L3 headers and output

    Logical Router Ingress Pipeline (for DHCP Relay):

    StageNamePurpose
    4lr_in_dhcp_relay_reqRewrite src/dst IPs, forward DHCP request to server
    20lr_in_dhcp_relay_resp_chkValidate DHCP relay response from server
    21lr_in_dhcp_relay_respRewrite headers and deliver response to client

    register bits for DHCP state

    the DHCP flow generation uses specific register bits to track state across pipeline stages (ovn/northd/northd.c:126,209-210,230):

    #define REGBIT_DHCP_OPTS_RESULT "reg0[3]"
    #define REGBIT_DHCP_RELAY_REQ_CHK "reg9[7]"
    #define REGBIT_DHCP_RELAY_RESP_CHK "reg9[8]"
    #define REG_DHCP_RELAY_DIP_IPV4 "reg2"
    • reg0[3] (REGBIT_DHCP_OPTS_RESULT): Set to 1 by the put_dhcp_opts / put_dhcpv6_opts action if the option encoding succeeds. The next stage (ls_in_dhcp_response) checks this bit to decide whether to generate the response packet.
    • reg9[7] (REGBIT_DHCP_RELAY_REQ_CHK): Set by dhcp_relay_req_chk action on the router if the relay request is valid.
    • reg9[8] (REGBIT_DHCP_RELAY_RESP_CHK): Set by dhcp_relay_resp_chk action on the router if the relay response is valid.
    • reg2 (REG_DHCP_RELAY_DIP_IPV4): Stores the original destination IP from the relay response for matching in the next stage.

    the entry point

    the function build_lswitch_dhcp_options_and_response() (ovn/northd/northd.c:10958-11008) is called for each logical switch port during the build_ls_lflows() pass:

    static void
    build_lswitch_dhcp_options_and_response(struct ovn_port *op,
    struct lflow_table *lflows,
    const struct shash *meter_groups)
    {
    ovs_assert(op->nbsp);
    if (!lsp_is_enabled(op->nbsp) || lsp_is_router(op->nbsp)) {
    return;
    }
    if (!op->nbsp->dhcpv4_options && !op->nbsp->dhcpv6_options) {
    return;
    }
    if (ls_dhcp_relay_port(op->od)) {
    /* Don't add DHCP server flows if DHCP Relay is enabled */
    return;
    }
    bool is_external = lsp_is_external(op->nbsp);
    if (is_external && (!ls_has_localnet_port(op->od) ||
    !op->nbsp->ha_chassis_group)) {
    return;
    }
    for (size_t i = 0; i < op->n_lsp_addrs; i++) {
    build_dhcpv4_options_flows(op, &op->lsp_addrs[i], op,
    is_external, meter_groups,
    lflows, op->lflow_ref);
    build_dhcpv6_options_flows(op, &op->lsp_addrs[i], op,
    is_external, meter_groups,
    lflows, op->lflow_ref);
    }
    }

    three guards before any DHCP flows are generated:

    1. the port must be enabled and not a router port
    2. the port must have DHCP options configured (dhcpv4_options or dhcpv6_options)
    3. DHCP relay must NOT be enabled on this switch (checked via ls_dhcp_relay_port())

    the helper ls_dhcp_relay_port() (ovn/northd/northd.c:10079-10083) simply reads the other_config:dhcp_relay_port key:

    static const char *
    ls_dhcp_relay_port(const struct ovn_datapath *od)
    {
    return smap_get(&od->nbs->other_config, "dhcp_relay_port");
    }

    building the DHCPv4 action string

    the function build_dhcpv4_action() (ovn/northd/northd.c:5956-6040) constructs the action strings that will become logical flow actions:

    static bool
    build_dhcpv4_action(struct ovn_port *op, ovs_be32 offer_ip,
    struct ds *options_action, struct ds *response_action,
    struct ds *ipv4_addr_match)
    {
    if (!op->nbsp->dhcpv4_options) {
    return false;
    }
    ovs_be32 host_ip, mask;
    char *error = ip_parse_masked(op->nbsp->dhcpv4_options->cidr, &host_ip,
    &mask);
    if (error || ((offer_ip ^ host_ip) & mask)) {
    free(error);
    return false;
    }
    const char *server_ip = smap_get(
    &op->nbsp->dhcpv4_options->options, "server_id");
    const char *server_mac = smap_get(
    &op->nbsp->dhcpv4_options->options, "server_mac");
    const char *lease_time = smap_get(
    &op->nbsp->dhcpv4_options->options, "lease_time");
    if (!(server_ip && server_mac && lease_time)) {
    return false;
    }
    /* ... */

    three options are mandatory: server_id, server_mac, and lease_time. if any is missing, the function returns false and no DHCP flows are generated for this port. no server_mac? no DHCP. simple as that.

    the function then builds two action strings:

    the options action (executed in ls_in_dhcp_options):

    ds_put_format(options_action,
    REGBIT_DHCP_OPTS_RESULT" = put_dhcp_opts(offerip = "
    IP_FMT", ", IP_ARGS(offer_ip));
    /* ... sorted option encoding ... */
    ds_put_cstr(options_action, "); next;");

    the response action (executed in ls_in_dhcp_response):

    ds_put_format(response_action, "eth.dst = eth.src; eth.src = %s; "
    "ip4.src = %s; udp.src = 67; udp.dst = 68; "
    "outport = inport; flags.loopback = 1; output;",
    server_mac, server_ip);

    the response action swaps MAC addresses (server MAC as src, client MAC as dst), sets the source IP to the server IP, swaps UDP ports (src=67, dst=68), and outputs the packet back out the ingress port with the loopback flag set.

    the generated logical flows

    the build_dhcpv4_options_flows() function (ovn/northd/northd.c:9900-9995) generates three flows per port:

    Flow 1: DHCP options encoding (stage ls_in_dhcp_options, priority 100):

    match: "inport == <port> && eth.src == <mac> &&
    (ip4.src == {<offer_ip>, 0.0.0.0}
    && ip4.dst == {<server_ip>, 255.255.255.255})
    && udp.src == 68 && udp.dst == 67"
    action: "reg0[3] = put_dhcp_opts(offerip = <offer_ip>,
    netmask = <mask>, router = <router>, ...); next;"

    this flow matches DHCP requests (DISCOVER/REQUEST) from the client. the match on ip4.src handles both initial requests (src=0.0.0.0) and renewals (src=offered IP). the match on ip4.dst handles both directed requests (dst=server_ip) and broadcasts (dst=255.255.255.255).

    the flow also includes a CoPP meter reference via WITH_CTRL_METER(copp_meter_get(COPP_DHCPV4_OPTS, ...)) and a hint via WITH_HINT(&op->nbsp->dhcpv4_options->header_).

    Flow 2: DHCP response generation (stage ls_in_dhcp_response, priority 100):

    match: "inport == <port> && eth.src == <mac> &&
    ip4 && udp.src == 68 && udp.dst == 67
    && reg0[3]"
    action: "eth.dst = eth.src; eth.src = <server_mac>;
    ip4.src = <server_ip>; udp.src = 67; udp.dst = 68;
    outport = inport; flags.loopback = 1; output;"

    this flow fires only if reg0[3] (REGBIT_DHCP_OPTS_RESULT) was set to 1 by the previous stage — meaning the put_dhcp_opts action succeeded.

    Flow 3: ACL bypass for DHCP replies (stage S_SWITCH_OUT_ACL_EVAL, priority 34000):

    match: "outport == <port> && eth.src == <server_mac>
    && ip4.src == <server_ip> && udp && udp.src == 67
    && udp.dst == 68"
    action: "reg1[0] = 1; next;"

    this is a high-priority ACL flow that ensures DHCP replies from ovn-controller bypass any drop ACLs. it sets REGBIT_ACL_VERDICT_ALLOW (reg1[0]) to 1, allowing the packet to proceed through the ACL pipeline without being dropped. this is generated only for non-external ports.

    the DHCPv6 equivalent (build_dhcpv6_options_flows, ovn/northd/northd.c:9997-10077) follows the same pattern but matches on multicast destination ff02::1:2 and UDP ports 546/547.

    default flows

    the function build_lswitch_dhcp_and_dns_defaults() (ovn/northd/northd.c:11016-11032) installs priority-0 default flows:

    ovn_lflow_add(lflows, od, S_SWITCH_IN_DHCP_OPTIONS, 0, "1", "next;", lflow_ref);
    ovn_lflow_add(lflows, od, S_SWITCH_IN_DHCP_RESPONSE, 0, "1", "next;", lflow_ref);

    these ensure that non-DHCP packets pass through these stages without being dropped.

    DHCP relay on the logical switch

    when DHCP relay is enabled, the function build_lswitch_dhcp_relay_flows() (ovn/northd/northd.c:10085-10162) generates a flow at stage S_SWITCH_IN_L2_LKUP (priority 100) that redirects DHCP requests to the router port:

    ds_put_format(
    match, "inport == %s && eth.src == %s && "
    "ip4.src == {0.0.0.0, %s/%u} && ip4.dst == 255.255.255.255 && "
    "udp.src == 68 && udp.dst == 67",
    op->json_key, op->lsp_addrs[0].ea_s,
    rp->lrp_networks.ipv4_addrs[0].network_s,
    rp->lrp_networks.ipv4_addrs[0].plen);
    ds_put_format(actions,
    "eth.dst = %s; outport = %s; next; /* DHCP_RELAY_REQ */",
    rp->lrp_networks.ea_s, sp->json_key);

    this rewrites the destination MAC to the router port’s MAC and outputs to the router port, effectively forwarding the DHCP request into the router pipeline.

    DHCP relay on the logical router

    the function build_dhcp_relay_flows_for_lrouter_port() (ovn/northd/northd.c:16815-16980) generates eight flows across three router stages. here’s the complete relay flow sequence:

    request path (client → server):

    Flow 1 (lr_in_ip_input, priority 110) — validate relay request:

    match: "inport == <router_port> &&
    ip4.src == {0.0.0.0, <network>/<prefix>} &&
    ip4.dst == 255.255.255.255 &&
    ip.frag == 0 && udp.src == 68 && udp.dst == 67"
    action: "reg9[7] = dhcp_relay_req_chk(<relay_ip>, <server_ip>); next;"

    Flow 2 (lr_in_dhcp_relay_req, priority 100) — forward valid request:

    match: "inport == <router_port> &&
    ip4.src == {0.0.0.0, <network>/<prefix>} &&
    ip4.dst == 255.255.255.255 &&
    udp.src == 68 && udp.dst == 67 &&
    reg9[7]"
    action: "ip4.src = <relay_ip>; ip4.dst = <server_ip>;
    udp.src = 67; next;"

    Flow 3 (lr_in_dhcp_relay_req, priority 1) — drop invalid request:

    match: "inport == <router_port> &&
    ip4.src == {0.0.0.0, <network>/<prefix>} &&
    ip4.dst == 255.255.255.255 &&
    udp.src == 68 && udp.dst == 67 &&
    reg9[7] == 0"
    action: "drop;"

    response path (server → client):

    Flow 4 (lr_in_ip_input, priority 110) — match non-fragmented response:

    match: "ip4.src == <server_ip> && ip4.dst == <relay_ip> &&
    ip.frag == 0 && udp.src == 67 && udp.dst == 67"
    action: "next;"

    Flow 5 (lr_in_dhcp_relay_resp_chk, priority 100) — validate relay response:

    match: "ip4.src == <server_ip> && ip4.dst == <relay_ip> &&
    udp.src == 67 && udp.dst == 67"
    action: "reg2 = ip4.dst;
    reg9[8] = dhcp_relay_resp_chk(<relay_ip>, <server_ip>); next;"

    Flow 6 (lr_in_dhcp_relay_resp, priority 100) — deliver valid response:

    match: "ip4.src == <server_ip> &&
    reg2 == <relay_ip> &&
    udp.src == 67 && udp.dst == 67 &&
    reg9[8]"
    action: "ip4.src = <relay_ip>; udp.dst = 68;
    outport = <router_port>; output;"

    Flow 7 (lr_in_dhcp_relay_resp, priority 1) — drop invalid response:

    match: "ip4.src == <server_ip> &&
    reg2 == <relay_ip> &&
    udp.src == 67 && udp.dst == 67 &&
    reg9[8] == 0"
    action: "drop;"

    Flow 8 — default passthrough for each relay stage (priority 0):

    "1" → "next;"

    the ovn action system: how put_dhcp_opts becomes openflow

    the action opcodes

    when northd generates a logical flow with put_dhcp_opts(...), this action must be encoded into an OpenFlow controller message. the action system defines five DHCP-related opcodes in ovn/include/ovn/actions.h:663-838:

    OpcodePurposeUserdata Format
    ACTION_OPCODE_PUT_DHCP_OPTSDHCPv4 option encodingOXM header + offset + offer_ip + DHCP options
    ACTION_OPCODE_PUT_DHCPV6_OPTSDHCPv6 option encodingOXM header + offset + DHCPv6 options
    ACTION_OPCODE_DHCP6_SERVERDHCPv6 server-side handling(actions in OF1.3 format)
    ACTION_OPCODE_DHCP_RELAY_REQ_CHKDHCP relay request validationrelay_ip + server_ip
    ACTION_OPCODE_DHCP_RELAY_RESP_CHKDHCP relay response validationrelay_ip + server_ip

    each opcode is preceded by an action_header (ovn/include/ovn/actions.h:854-858):

    struct action_header {
    ovs_be32 opcode; /* One of ACTION_OPCODE_* */
    uint8_t pad[4];
    };
    BUILD_ASSERT_DECL(sizeof(struct action_header) == 8);

    parsing DHCP actions

    when ovn-controller processes a logical flow, it parses the action string via lflow_parse_actions() (ovn/controller/lflow.c:365-408):

    static bool
    lflow_parse_actions(const struct sbrec_logical_flow *lflow,
    const struct lflow_ctx_in *l_ctx_in,
    struct sset *template_vars_ref,
    struct ofpbuf *ovnacts_out,
    struct expr **prereqs_out)
    {
    bool ingress = !strcmp(lflow->pipeline, "ingress");
    struct ovnact_parse_params pp = {
    .symtab = &symtab,
    .dhcp_opts = l_ctx_in->dhcp_opts,
    .dhcpv6_opts = l_ctx_in->dhcpv6_opts,
    .nd_ra_opts = l_ctx_in->nd_ra_opts,
    .controller_event_opts = l_ctx_in->controller_event_opts,
    .pipeline = ingress ? OVNACT_P_INGRESS : OVNACT_P_EGRESS,
    .n_tables = ingress ? LOG_PIPELINE_INGRESS_LEN : LOG_PIPELINE_EGRESS_LEN,
    .cur_ltable = lflow->table_id,
    };
    /* ... */
    char *error = ovnacts_parse_string(lex_str_get(&actions_s), &pp,
    ovnacts_out, prereqs_out);

    the ovnact_parse_params struct (ovn/include/ovn/actions.h:860-898) carries dhcp_opts and dhcpv6_opts as const struct hmap * — these are the option definition maps loaded from the SB database. during parsing, when the parser encounters put_dhcp_opts(offerip = ..., netmask = ..., ...), it looks up each option name (like "netmask") in the dhcp_opts hmap to resolve its DHCP option code and type.

    the parser entry point for put_dhcp_opts is in parse_set_action() (ovn/lib/actions.c:5920-5927):

    } else if (!strcmp(ctx->lexer->token.s, "put_dhcp_opts")
    && lexer_lookahead(ctx->lexer) == LEX_T_LPAREN) {
    parse_put_dhcp_opts(ctx, &lhs, ovnact_put_PUT_DHCPV4_OPTS(
    ctx->ovnacts));
    } else if (!strcmp(ctx->lexer->token.s, "put_dhcpv6_opts")
    && lexer_lookahead(ctx->lexer) == LEX_T_LPAREN) {
    parse_put_dhcp_opts(ctx, &lhs, ovnact_put_PUT_DHCPV6_OPTS(
    ctx->ovnacts));

    the parse_put_dhcp_opts() function (ovn/lib/actions.c:2976-2995) selects the appropriate option map based on the action type:

    static void
    parse_put_dhcp_opts(struct action_context *ctx,
    const struct expr_field *dst,
    struct ovnact_put_opts *po)
    {
    const struct hmap *dhcp_opts =
    (po->ovnact.type == OVNACT_PUT_DHCPV6_OPTS) ?
    ctx->pp->dhcpv6_opts : ctx->pp->dhcp_opts;
    const char *opts_type =
    (po->ovnact.type == OVNACT_PUT_DHCPV6_OPTS) ? "DHCPv6" : "DHCPv4";
    parse_put_opts(ctx, dst, po, dhcp_opts, opts_type);
    if (!ctx->lexer->error && po->ovnact.type == OVNACT_PUT_DHCPV4_OPTS
    && !find_opt(po->options, po->n_options, 0)) {
    lexer_error(ctx->lexer,
    "put_dhcp_opts requires offerip to be specified.");
    return;
    }
    }

    critical validation: DHCPv4 put_dhcp_opts requires offerip (option code 0) to be present. without it, parsing fails. makes sense — what’s a DHCP offer without an IP to offer?

    encoding DHCP actions into openflow

    the struct ovnact_put_opts (ovn/include/ovn/actions.h:406-411) stores the parsed options:

    struct ovnact_put_opts {
    struct ovnact ovnact;
    struct expr_field dst; /* 1-bit destination field. */
    struct ovnact_gen_option *options;
    size_t n_options;
    };

    each option is an ovnact_gen_option (ovn/include/ovn/actions.h:400-403):

    struct ovnact_gen_option {
    const struct gen_opts_map *option;
    struct expr_constant_set value;
    };

    the encoding function encode_PUT_DHCPV4_OPTS() (ovn/lib/actions.c:3228-3292) converts the parsed options into an OpenFlow controller message:

    static void
    encode_PUT_DHCPV4_OPTS(const struct ovnact_put_opts *pdo,
    const struct ovnact_encode_params *ep,
    struct ofpbuf *ofpacts)
    {
    struct mf_subfield dst = expr_resolve_field(&pdo->dst);
    size_t oc_offset = encode_start_controller_op(ACTION_OPCODE_PUT_DHCP_OPTS,
    true, ep->ctrl_meter_id,
    ofpacts);
    nx_put_header(ofpacts, dst.field->id, OFP15_VERSION, false);
    ovs_be32 ofs = htonl(dst.ofs);
    ofpbuf_put(ofpacts, &ofs, sizeof ofs);
    /* Encode offerip first — it must be first in the DHCP response */
    const struct ovnact_gen_option *offerip_opt = find_opt(
    pdo->options, pdo->n_options, 0);
    const struct expr_constant *c =
    vector_get_ptr(&offerip_opt->value.values, 0);
    ovs_be32 offerip = c->value.ipv4;
    ofpbuf_put(ofpacts, &offerip, sizeof offerip);
    /* Encode bootfile_name (67), bootfile_name_alt (254), next_server (253)
    * in that specific order, then encode everything else */
    /* ... */
    for (size_t i = 0; i < pdo->n_options; i++) {
    const struct ovnact_gen_option *o = &pdo->options[i];
    if (o != offerip_opt && o != boot_opt && o != boot_alt_opt &&
    o != next_server_opt) {
    encode_put_dhcpv4_option(o, ofpacts);
    }
    }
    encode_finish_controller_op(oc_offset, ofpacts);
    }

    the encoding order matters:

    1. Offer IP (always first)
    2. bootfile_name (option 67) — for PXE/iPXE boot
    3. bootfile_name_alt (option 254) — iPXE alternate bootfile
    4. next_server (option 253) — TFTP server address
    5. All other options — in the order they were defined

    the final controller message format is:

    [action_header: 8 bytes (opcode=PUT_DHCP_OPTS + padding)]
    [OXM header: 4-8 bytes (field MFF_REG0, offset 3)]
    [offer_ip: 4 bytes]
    [DHCP option: code + len + value]
    [DHCP option: code + len + value]
    ...

    option encoding by type

    the function encode_put_dhcpv4_option() (ovn/lib/actions.c:3026-3192) handles each option type differently:

    static void
    encode_put_dhcpv4_option(const struct ovnact_gen_option *o,
    struct ofpbuf *ofpacts)
    {
    uint8_t *opt_header = ofpbuf_put_zeros(ofpacts, 2);
    opt_header[0] = o->option->code;
    const struct expr_constant *c = vector_get_ptr(&o->value.values, 0);
    size_t n_values = vector_len(&o->value.values);
    if (!strcmp(o->option->type, "bool") ||
    !strcmp(o->option->type, "uint8")) {
    opt_header[1] = 1;
    ofpbuf_put(ofpacts, &c->value.u8_val, 1);
    } else if (!strcmp(o->option->type, "uint16")) {
    opt_header[1] = 2;
    ofpbuf_put(ofpacts, &c->value.be16_int, 2);
    } else if (!strcmp(o->option->type, "uint32")) {
    opt_header[1] = 4;
    ofpbuf_put(ofpacts, &c->value.be32_int, 4);
    } else if (!strcmp(o->option->type, "ipv4")) {
    opt_header[1] = n_values * sizeof(ovs_be32);
    for (size_t i = 0; i < n_values; i++) {
    ofpbuf_put(ofpacts, &c[i].value.ipv4, sizeof(ovs_be32));
    }
    } else if (!strcmp(o->option->type, "static_routes")) {
    /* RFC 3442 classless static route encoding */
    /* ... complex encoding with plen + destination + router ... */
    } else if (!strcmp(o->option->type, "str")) {
    opt_header[1] = strlen(c->string);
    ofpbuf_put(ofpacts, c->string, opt_header[1]);
    } else if (!strcmp(o->option->type, "host_id")) {
    /* Can be string or IPv4 */
    if (o->value.type == EXPR_C_STRING) {
    opt_header[1] = strlen(c->string);
    ofpbuf_put(ofpacts, c->string, opt_header[1]);
    } else {
    opt_header[1] = sizeof(ovs_be32);
    ofpbuf_put(ofpacts, &c->value.ipv4, sizeof(ovs_be32));
    }
    } else if (!strcmp(o->option->type, "domains")) {
    /* RFC 1035 DNS-encoded domain search list with compression pointers */
    /* ... complex encoding ... */
    }
    }

    the static_routes type deserves special attention — it encodes according to RFC 3442, with variable-length destination descriptors:

    Destination descriptor examples:
    0 → 0 (default route)
    10.0.0.0/8 → 8.10
    10.0.0.0/24 → 24.10.0.0
    10.229.0.128/25 → 25.10.229.0.128

    the domains type encodes domain search lists using RFC 1035 label compression, where shared suffixes are replaced with two-byte pointers (0xC0xx) to avoid duplication.

    DHCPv6 option encoding

    the DHCPv6 encoding function encode_put_dhcpv6_option() (ovn/lib/actions.c:3194-3226) is simpler:

    static void
    encode_put_dhcpv6_option(const struct ovnact_gen_option *o,
    struct ofpbuf *ofpacts)
    {
    struct dhcpv6_opt_header *opt = ofpbuf_put_uninit(ofpacts, sizeof *opt);
    const struct expr_constant *c = vector_get_ptr(&o->value.values, 0);
    size_t n_values = vector_len(&o->value.values);
    size_t size;
    opt->code = htons(o->option->code);
    if (!strcmp(o->option->type, "ipv6")) {
    size = n_values * sizeof(struct in6_addr);
    opt->len = htons(size);
    for (size_t i = 0; i < n_values; i++) {
    ofpbuf_put(ofpacts, &c[i].value.ipv6, sizeof(struct in6_addr));
    }
    } else if (!strcmp(o->option->type, "mac")) {
    size = sizeof(struct eth_addr);
    opt->len = htons(size);
    ofpbuf_put(ofpacts, &c->value.mac, size);
    } else if (!strcmp(o->option->type, "str")) {
    size = strlen(c->string);
    opt->len = htons(size);
    ofpbuf_put(ofpacts, c->string, size);
    } else if (!strcmp(o->option->type, "domain")) {
    char *encoded = encode_fqdn_string(c->string, &size);
    opt->len = htons(size);
    ofpbuf_put(ofpacts, encoded, size);
    free(encoded);
    }
    }

    DHCPv6 option headers use 16-bit code and length fields (vs. 8-bit for DHCPv4).

    DHCP relay action encoding

    the encode_dhcpv4_relay_chk() function (ovn/lib/actions.c:2883-2901) encodes the relay check action:

    static void
    encode_dhcpv4_relay_chk(const struct ovnact_dhcp_relay *dhcp_relay,
    const struct ovnact_encode_params *ep,
    struct ofpbuf *ofpacts,
    enum action_opcode dhcp_relay_opcode)
    {
    struct mf_subfield dst = expr_resolve_field(&dhcp_relay->dst);
    size_t oc_offset = encode_start_controller_op(dhcp_relay_opcode, true,
    ep->ctrl_meter_id,
    ofpacts);
    nx_put_header(ofpacts, dst.field->id, OFP15_VERSION, false);
    ovs_be32 ofs = htonl(dst.ofs);
    ofpbuf_put(ofpacts, &ofs, sizeof ofs);
    ofpbuf_put(ofpacts, &dhcp_relay->relay_ipv4,
    sizeof(dhcp_relay->relay_ipv4));
    ofpbuf_put(ofpacts, &dhcp_relay->server_ipv4,
    sizeof(dhcp_relay->server_ipv4));
    encode_finish_controller_op(oc_offset, ofpacts);
    }

    the userdata contains: the result field (OXM header + offset), the 32-bit relay IP, and the 32-bit server IP.


    ovn controller: reading dhcp options from sb

    the engine node

    OVN controller uses an incremental processing engine. the dhcp_options engine node (ovn/controller/ovn-controller.c:3811-3862) reads the SB DHCP option tables:

    struct ed_type_dhcp_options {
    struct hmap v4_opts;
    struct hmap v6_opts;
    };
    static enum engine_node_state
    en_dhcp_options_run(struct engine_node *node, void *data)
    {
    struct ed_type_dhcp_options *dhcp_opts = data;
    const struct sbrec_dhcp_options_table *dhcp_table =
    EN_OVSDB_GET(engine_get_input("SB_dhcp_options", node));
    const struct sbrec_dhcpv6_options_table *dhcpv6_table =
    EN_OVSDB_GET(engine_get_input("SB_dhcpv6_options", node));
    dhcp_opts_clear(&dhcp_opts->v4_opts);
    dhcp_opts_clear(&dhcp_opts->v6_opts);
    const struct sbrec_dhcp_options *dhcp_opt_row;
    SBREC_DHCP_OPTIONS_TABLE_FOR_EACH (dhcp_opt_row, dhcp_table) {
    dhcp_opt_add(&dhcp_opts->v4_opts, dhcp_opt_row->name,
    dhcp_opt_row->code, dhcp_opt_row->type);
    }
    const struct sbrec_dhcpv6_options *dhcpv6_opt_row;
    SBREC_DHCPV6_OPTIONS_TABLE_FOR_EACH (dhcpv6_opt_row, dhcpv6_table) {
    dhcp_opt_add(&dhcp_opts->v6_opts, dhcpv6_opt_row->name,
    dhcpv6_opt_row->code, dhcpv6_opt_row->type);
    }
    return EN_UPDATED;
    }

    the engine graph connections (ovn/controller/ovn-controller.c:7075-7078):

    engine_add_input(&en_dhcp_options, &en_sb_dhcp_options, NULL);
    engine_add_input(&en_dhcp_options, &en_sb_dhcpv6_options, NULL);
    engine_add_input(&en_lflow_output, &en_dhcp_options, NULL);
    en_sb_dhcp_options ──→ en_dhcp_options ──→ en_lflow_output
    en_sb_dhcpv6_options ─┘

    the NULL handler means any change in either SB table triggers a full recompute.

    feeding into logical flow processing

    the init_lflow_ctx() function (ovn/controller/ovn-controller.c:3995-4035) passes the DHCP option maps into the logical flow context:

    struct ed_type_dhcp_options *dhcp_opts =
    engine_get_input_data("dhcp_options", node);
    /* ... */
    l_ctx_in->dhcp_opts = &dhcp_opts->v4_opts;
    l_ctx_in->dhcpv6_opts = &dhcp_opts->v6_opts;

    these maps flow into ovnact_parse_params during action parsing, allowing the parser to resolve option names to codes.


    ovn controller pinctrl: handling dhcp packet-ins

    this is where the rubber meets the road. when a DHCP packet matches a logical flow with a put_dhcp_opts action, the packet is sent to ovn-controller as a packet-in. the pinctrl module handles it.

    the dispatch table

    the function process_packet_in() (ovn/controller/pinctrl.c:3746-3919) is the central dispatcher. it reads the opcode from the packet-in userdata and dispatches to the appropriate handler:

    switch (ntohl(ah->opcode)) {
    /* ... */
    case ACTION_OPCODE_DHCP_RELAY_REQ_CHK:
    pinctrl_handle_dhcp_relay_req_chk(swconn, &packet, &pin,
    &userdata, &continuation);
    break;
    case ACTION_OPCODE_DHCP_RELAY_RESP_CHK:
    pinctrl_handle_dhcp_relay_resp_chk(swconn, &packet, &pin,
    &userdata, &continuation);
    break;
    case ACTION_OPCODE_PUT_DHCP_OPTS:
    pinctrl_handle_put_dhcp_opts(swconn, &packet, &pin, &headers,
    &userdata, &continuation);
    break;
    case ACTION_OPCODE_PUT_DHCPV6_OPTS:
    pinctrl_handle_put_dhcpv6_opts(swconn, &packet, &pin, &userdata,
    &continuation);
    break;
    case ACTION_OPCODE_DHCP6_SERVER:
    ovs_mutex_lock(&pinctrl_mutex);
    pinctrl_handle_dhcp6_server(swconn, &headers, &packet,
    &pin.flow_metadata);
    ovs_mutex_unlock(&pinctrl_mutex);
    break;
    /* ... */
    }

    DHCP packet validation: dhcp_get_hdr_from_pkt

    before handling any DHCP packet, the function dhcp_get_hdr_from_pkt() (ovn/controller/pinctrl.c:2112-2168) validates the packet structure:

    static const struct dhcp_header *
    dhcp_get_hdr_from_pkt(struct dp_packet *pkt_in, const char **in_dhcp_pptr,
    const char *end)
    {
    *in_dhcp_pptr = dp_packet_get_udp_payload(pkt_in);
    if (*in_dhcp_pptr == NULL) {
    VLOG_WARN_RL(&rl, "DHCP: Invalid or incomplete DHCP packet received");
    return NULL;
    }
    const struct dhcp_header *dhcp_hdr
    = (const struct dhcp_header *) *in_dhcp_pptr;
    (*in_dhcp_pptr) += sizeof *dhcp_hdr;
    if (*in_dhcp_pptr > end) {
    VLOG_WARN_RL(&rl, "DHCP: Invalid or incomplete DHCP packet received, "
    "bad data length");
    return NULL;
    }
    if (dhcp_hdr->htype != 0x1) {
    VLOG_WARN_RL(&rl, "DHCP: Packet is recieved with "
    "unsupported hardware type");
    return NULL;
    }
    if (dhcp_hdr->hlen != 0x6) {
    VLOG_WARN_RL(&rl, "DHCP: Packet is recieved with "
    "unsupported hardware length");
    return NULL;
    }
    ovs_be32 magic_cookie = htonl(DHCP_MAGIC_COOKIE);
    if ((*in_dhcp_pptr) + sizeof magic_cookie > end ||
    get_unaligned_be32((const void *) (*in_dhcp_pptr)) != magic_cookie) {
    VLOG_WARN_RL(&rl, "DHCP: Magic cookie not present in the DHCP packet");
    return NULL;
    }
    (*in_dhcp_pptr) += sizeof magic_cookie;
    return dhcp_hdr;
    }

    validation checks:

    1. UDP payload must exist
    2. DHCP header must fit within the packet
    3. Hardware type must be Ethernet (0x1)
    4. Hardware length must be 6 (0x6, ETH_ADDR_LEN)
    5. Magic cookie (0x63825363) must be present after the header

    DHCP option parsing: dhcp_parse_options

    the function dhcp_parse_options() (ovn/controller/pinctrl.c:2180-2243) walks the DHCP options area and extracts the fields needed for decision-making:

    struct parsed_dhcp_options {
    uint8_t dhcp_msg_type;
    ovs_be32 request_ip;
    ovs_be32 server_id;
    ovs_be32 netmask;
    ovs_be32 router_ip;
    bool ipxe_req;
    };
    static struct parsed_dhcp_options
    dhcp_parse_options(const char **in_dhcp_pptr, const char *end)
    {
    struct parsed_dhcp_options parsed_dhcp_opts = {0};
    while ((*in_dhcp_pptr) < end) {
    const struct dhcp_opt_header *in_dhcp_opt =
    (const struct dhcp_opt_header *) *in_dhcp_pptr;
    if (in_dhcp_opt->code == DHCP_OPT_END) {
    break;
    }
    if (in_dhcp_opt->code == DHCP_OPT_PAD) {
    (*in_dhcp_pptr) += 1;
    continue;
    }
    (*in_dhcp_pptr) += sizeof *in_dhcp_opt;
    if ((*in_dhcp_pptr) > end) {
    break;
    }
    (*in_dhcp_pptr) += in_dhcp_opt->len;
    if ((*in_dhcp_pptr) > end) {
    break;
    }
    switch (in_dhcp_opt->code) {
    case DHCP_OPT_MSG_TYPE: /* code 53 */
    if (in_dhcp_opt->len == 1) {
    const uint8_t *dhcp_msg_type = DHCP_OPT_PAYLOAD(in_dhcp_opt);
    parsed_dhcp_opts.dhcp_msg_type = *dhcp_msg_type;
    }
    break;
    case DHCP_OPT_REQ_IP: /* code 50 */
    if (in_dhcp_opt->len == 4) {
    parsed_dhcp_opts.request_ip = get_unaligned_be32(
    DHCP_OPT_PAYLOAD(in_dhcp_opt));
    }
    break;
    case OVN_DHCP_OPT_CODE_SERVER_ID: /* code 54 */
    if (in_dhcp_opt->len == 4) {
    parsed_dhcp_opts.server_id = get_unaligned_be32(
    DHCP_OPT_PAYLOAD(in_dhcp_opt));
    }
    break;
    case OVN_DHCP_OPT_CODE_NETMASK: /* code 1 */
    if (in_dhcp_opt->len == 4) {
    parsed_dhcp_opts.netmask = get_unaligned_be32(
    DHCP_OPT_PAYLOAD(in_dhcp_opt));
    }
    break;
    case OVN_DHCP_OPT_CODE_ROUTER_IP: /* code 3 */
    if (in_dhcp_opt->len == 4) {
    parsed_dhcp_opts.router_ip = get_unaligned_be32(
    DHCP_OPT_PAYLOAD(in_dhcp_opt));
    }
    break;
    case DHCP_OPT_ETHERBOOT: /* code 175 */
    parsed_dhcp_opts.ipxe_req = true;
    break;
    default:
    break;
    }
    }
    return parsed_dhcp_opts;
    }

    the parser specifically extracts six fields because they’re needed for validation and decision-making downstream: message type, requested IP, server ID, netmask, router IP, and iPXE request flag.

    DHCPv4: building the response packet

    the function pinctrl_handle_put_dhcp_opts() (ovn/controller/pinctrl.c:2604-2944) is the core DHCPv4 handler — 340 lines of packet construction. i read every single one of them. here’s the control flow:

    step 1: parse userdata

    const struct mf_field *f;
    enum ofperr ofperr = nx_pull_header(userdata, NULL, &f, NULL);
    /* ... */
    ovs_be32 *ofsp = ofpbuf_try_pull(userdata, sizeof *ofsp);
    ovs_be32 *offer_ip = ofpbuf_try_pull(userdata, sizeof *offer_ip);

    the userdata contains the result field (where to write success/failure), the offer IP, and the encoded DHCP options.

    step 2: parse the incoming DHCP packet

    const struct dhcp_header *in_dhcp_data =
    dhcp_get_hdr_from_pkt(pkt_in, &in_dhcp_ptr, end);
    if (in_dhcp_data->op != DHCP_OP_REQUEST) {
    VLOG_WARN_RL(&rl, "Invalid opcode in the DHCP packet: %d",
    in_dhcp_data->op);
    goto exit;
    }
    struct parsed_dhcp_options dhcp_opts = dhcp_parse_options(&in_dhcp_ptr, end);
    if (!dhcp_opts.request_ip) {
    dhcp_opts.request_ip = in_dhcp_data->ciaddr;
    }

    if the requested IP (option 50) is not present, the handler falls back to ciaddr from the DHCP header.

    step 3: determine response type

    switch (dhcp_opts.dhcp_msg_type) {
    case DHCP_MSG_DISCOVER:
    msg_type = DHCP_MSG_OFFER;
    break;
    case DHCP_MSG_REQUEST: {
    msg_type = DHCP_MSG_ACK;
    if (dhcp_opts.request_ip != *offer_ip) {
    msg_type = DHCP_MSG_NAK;
    }
    break;
    }
    case OVN_DHCP_MSG_RELEASE:
    /* Just log it */
    break;
    case OVN_DHCP_MSG_INFORM: {
    msg_type = DHCP_MSG_ACK;
    /* Strip lease/netmask/T1/T2 options from reply */
    break;
    }
    case OVN_DHCP_MSG_DECLINE:
    /* Log and exit */
    goto exit;
    }

    the DISCOVER→OFFER mapping is straightforward. for REQUEST, the handler validates that the requested IP matches the offer IP — if not, it sends a NAK. for INFORM (RFC 2131 section 3.4), the handler strips time-lease and netmask options from the reply.

    step 4: handle iPXE bootfile selection

    the handler processes the bootfile options with iPXE awareness:

    case DHCP_OPT_BOOTFILE_CODE:
    /* Check if bootfile_name_alt (254) follows */
    if (next_dhcp_opt->code == DHCP_OPT_BOOTFILE_ALT_CODE) {
    if (!dhcp_opts.ipxe_req) {
    /* Not iPXE: skip alt, use standard bootfile */
    ofpbuf_pull(reply_dhcp_opts_ptr, len);
    next_dhcp_opt->code = DHCP_OPT_BOOTFILE_CODE;
    } else {
    /* iPXE: use alt bootfile, skip standard */
    /* ... swap logic ... */
    }
    }

    step 5: construct the reply packet

    uint16_t new_l4_size = UDP_HEADER_LEN + DHCP_HEADER_LEN + 16;
    if (msg_type != DHCP_MSG_NAK) {
    new_l4_size += reply_dhcp_opts_ptr->size;
    }
    struct dp_packet pkt_out;
    dp_packet_init(&pkt_out, new_packet_size);
    /* Copy L2 and L3 headers from pkt_in */
    dp_packet_put(&pkt_out, dp_packet_pull(pkt_in, pkt_in->l4_ofs), pkt_in->l4_ofs);
    struct udp_header *udp = dp_packet_put(
    &pkt_out, dp_packet_pull(pkt_in, UDP_HEADER_LEN), UDP_HEADER_LEN);
    struct dhcp_header *dhcp_data = dp_packet_put(
    &pkt_out, dp_packet_pull(pkt_in, DHCP_HEADER_LEN), DHCP_HEADER_LEN);
    dhcp_data->op = DHCP_OP_REPLY;
    if (dhcp_opts.dhcp_msg_type != OVN_DHCP_MSG_INFORM) {
    dhcp_data->yiaddr = (msg_type == DHCP_MSG_NAK) ? 0 : *offer_ip;
    dhcp_data->siaddr = (msg_type == DHCP_MSG_NAK) ? 0 : next_server;
    }

    the reply packet structure is:

    ┌──────────────────────────────────────────────────────────────┐
    │ L2 Header (copied from request) │
    ├──────────────────────────────────────────────────────────────┤
    │ L3 Header (copied, with ip_dst updated) │
    ├──────────────────────────────────────────────────────────────┤
    │ UDP Header (src=67, dst=68) │
    ├──────────────────────────────────────────────────────────────┤
    │ DHCP Header (op=REPLY, yiaddr=offer_ip, siaddr=next_server) │
    ├──────────────────────────────────────────────────────────────┤
    │ Magic Cookie (0x63825363) │
    ├──────────────────────────────────────────────────────────────┤
    │ Option 53: Message Type (OFFER/ACK/NAK) │
    ├──────────────────────────────────────────────────────────────┤
    │ [Encoded DHCP options from userdata] │
    ├──────────────────────────────────────────────────────────────┤
    │ 4 bytes padding + Option END (0xFF) │
    └──────────────────────────────────────────────────────────────┘

    step 6: handle broadcast flag

    ovs_be32 ip_dst;
    if (!is_dhcp_flags_broadcast(dhcp_data->flags)) {
    ip_dst = *offer_ip;
    } else {
    ip_dst = htonl(0xffffffff);
    }
    put_16aligned_be32(&out_ip->ip_dst, ip_dst);

    if the client set the broadcast flag, the response is sent as a broadcast (255.255.255.255); otherwise, it’s unicast to the offered IP.

    step 7: set success and resume the pipeline

    success = 1;
    exit:
    if (!ofperr) {
    union mf_subvalue sv;
    sv.u8_val = success;
    mf_write_subfield(&dst, &sv, &pin->flow_metadata);
    }
    queue_msg(swconn, ofputil_encode_resume(pin, continuation, proto));

    the handler writes 1 (success) or 0 (failure) into reg0[3] via the mf_write_subfield call, then resumes the logical pipeline with the modified packet.

    finally-done-with-this-handler

    DHCPv6: building the response packet

    the function pinctrl_handle_put_dhcpv6_opts() (ovn/controller/pinctrl.c:3138-3364) follows a similar pattern but for DHCPv6:

    message type mapping:

    switch (hdr->msg_type) {
    case DHCPV6_MSG_TYPE_SOLICIT:
    out_dhcpv6_msg_type = DHCPV6_MSG_TYPE_ADVT;
    break;
    case DHCPV6_MSG_TYPE_REQUEST:
    case DHCPV6_MSG_TYPE_CONFIRM:
    case DHCPV6_MSG_TYPE_DECLINE:
    case DHCPV6_MSG_TYPE_INFO_REQ:
    out_dhcpv6_msg_type = DHCPV6_MSG_TYPE_REPLY;
    break;
    case DHCPV6_MSG_TYPE_RELEASE:
    out_dhcpv6_msg_type = DHCPV6_MSG_TYPE_REPLY;
    status_only = true;
    break;
    }

    parsing client options:

    the handler extracts IA_NA (for IAID), Client ID, User Class (for iPXE detection), and FQDN flags from the client’s packet:

    for (in_opt = next_dhcpv6_opt(DHCPV6_PAYLOAD(hdr), opts_len, &len, &opt_len);
    in_opt;
    in_opt = next_dhcpv6_opt(DHCPV6_PAYLOAD(hdr), opts_len, &len, &opt_len)) {
    switch (ntohs(in_opt->code)) {
    case DHCPV6_OPT_IA_NA_CODE:
    iaid = opt_ia_na->iaid;
    break;
    case DHCPV6_OPT_CLIENT_ID_CODE:
    in_opt_client_id = in_opt;
    break;
    case DHCPV6_OPT_USER_CLASS:
    if (!strncmp(user_class + DHCPV6_UC_PXE_OFFSET, "iPXE", 4)) {
    ipxe_req = true;
    }
    break;
    case DHCPV6_OPT_FQDN_CODE:
    fqdn_flags = *(uint8_t *) DHCPV6_OPT_PAYLOAD(in_opt);
    break;
    }
    }

    composing the response:

    the handler delegates option composition to compose_out_dhcpv6_opts() (ovn/controller/pinctrl.c:2967-3098), which processes each option from the userdata:

    • Server ID (code 2): Re-encoded with encode_dhcpv6_server_id_opt()
    • IA Address (code 5): Wrapped in an IA_NA option with the client’s IAID, T1=T2=infinity
    • DNS Server (code 23): Copied with header
    • Domain Search (code 24): Copied with length prefix
    • Boot File URL (code 59/254): Conditionally included based on iPXE detection
    • FQDN (code 39): Flags always include N=1 (server-managed); if client sent S, O is also set

    packet construction:

    uint16_t new_l4_size
    = (UDP_HEADER_LEN + 4 + sizeof *in_opt_client_id +
    ntohs(in_opt_client_id->len) + out_dhcpv6_opts.size);
    struct dp_packet pkt_out;
    /* Copy L2/L3 from pkt_in */
    /* Copy UDP header and DHCPv6 header (preserving transaction ID) */
    *out_dhcpv6 = out_dhcpv6_msg_type; /* Set message type */
    /* Copy Client Identifier */
    dp_packet_put(&pkt_out, in_opt_client_id,
    sizeof *in_opt_client_id + ntohs(in_opt_client_id->len));
    /* Copy composed options */
    dp_packet_put(&pkt_out, out_dhcpv6_opts.data, out_dhcpv6_opts.size);
    /* Compute UDP checksum */
    uint32_t csum = packet_csum_pseudoheader6(dp_packet_l3(&pkt_out));
    csum = csum_continue(csum, out_udp, dp_packet_size(&pkt_out) - ...);
    out_udp->udp_csum = csum_finish(csum);

    DHCPv6 prefix delegation handling

    the function pinctrl_handle_dhcp6_server() (ovn/controller/pinctrl.c:1155-1184) handles DHCPv6 server-side messages (Advertisements and Replies) for prefix delegation:

    static void
    pinctrl_handle_dhcp6_server(struct rconn *swconn, const struct flow *ip_flow,
    struct dp_packet *pkt_in, const struct match *md)
    {
    if (ip_flow->dl_type != htons(ETH_TYPE_IPV6) ||
    ip_flow->nw_proto != IPPROTO_UDP) {
    return;
    }
    struct udp_header *udp_in = dp_packet_l4(pkt_in);
    size_t dlen = MIN(ntohs(udp_in->udp_len), dp_packet_l4_size(pkt_in));
    if (dlen < UDP_HEADER_LEN + DHCPV6_HEADER_LEN) {
    return;
    }
    const struct dhcpv6_header *hdr = dp_packet_get_udp_payload(pkt_in);
    switch (hdr->msg_type) {
    case DHCPV6_MSG_TYPE_ADVT:
    pinctrl_parse_dhcpv6_advt(swconn, ip_flow, DHCPV6_PAYLOAD(hdr),
    opts_len, md);
    break;
    case DHCPV6_MSG_TYPE_REPLY:
    pinctrl_parse_dhcpv6_reply(DHCPV6_PAYLOAD(hdr), opts_len, ip_flow);
    break;
    }
    }

    for Advertisements, pinctrl_parse_dhcpv6_advt() (ovn/controller/pinctrl.c:908-1053) extracts IA_PD options with prefix delegation info and constructs a DHCPv6 Request packet to send back to the server:

    eth_compose(&packet, (struct eth_addr) ETH_ADDR_C(33,33,00,01,00,02),
    ip_flow->dl_dst, ETH_TYPE_IPV6, IPV6_HEADER_LEN);
    struct udp_header *udp_h = compose_ipv6(&packet, IPPROTO_UDP,
    &ip_flow->ipv6_dst,
    &in6addr_all_dhcp_agents,
    0, 0, 255,
    data_len + UDP_HEADER_LEN + 4);
    /* ... */
    unsigned char *dhcp_hdr = (unsigned char *)(udp_h + 1);
    *dhcp_hdr = DHCPV6_MSG_TYPE_REQUEST;
    memcpy(dhcp_hdr + 4, data, data_len);

    DHCP relay: request validation

    the function pinctrl_handle_dhcp_relay_req_chk() (ovn/controller/pinctrl.c:2245-2388) validates and modifies incoming DHCP relay requests:

    validation checks (in order):

    1. Parse the result field from userdata
    2. Parse relay_ip and server_ip from userdata
    3. Validate DHCP packet structure via dhcp_get_hdr_from_pkt()
    4. Verify op == DHCP_OP_REQUEST (opcode must be 1)
    5. Verify giaddr == 0 (relay agent IP must not already be set)
    6. Parse DHCP options and verify message type is present
    7. If request_ip is not in option 50, fall back to ciaddr

    packet modification:

    uint8_t hops = dhcp_data->hops + 1;
    if (udp->udp_csum) {
    udp->udp_csum = recalc_csum16(udp->udp_csum,
    htons((uint16_t) dhcp_data->hops), htons((uint16_t) hops));
    }
    dhcp_data->hops = hops;
    dhcp_data->giaddr = *relay_ip;
    if (udp->udp_csum) {
    udp->udp_csum = recalc_csum32(udp->udp_csum,
    0, dhcp_data->giaddr);
    }

    the handler increments the hop count and sets giaddr (gateway IP address) to the relay IP. UDP checksums are incrementally updated for both changes.

    DHCP relay: response validation

    the function pinctrl_handle_dhcp_relay_resp_chk() (ovn/controller/pinctrl.c:2390-2600) validates incoming DHCP relay responses with extensive security checks:

    validation checks (in order):

    1. Parse result field, relay_ip, server_ip from userdata
    2. Validate DHCP packet structure
    3. Verify op == DHCP_OP_REPLY (opcode must be 2)
    4. Verify giaddr != 0 (must be set in the relay request)
    5. Verify giaddr == relay_ip (giaddr must match the relay’s IP)
    6. Parse DHCP options and verify message type is present
    7. Verify server_id (option 54) is present
    8. Verify server_id == server_ip (server identifier must match configured server)
    9. For OFFER/ACK messages: verify (yiaddr & netmask) == (giaddr & netmask) — allocated IP must be in the same subnet as the relay
    10. For OFFER/ACK messages: if router_ip is present, log a warning if router_ip != giaddr (but continue processing)

    packet modification for delivery:

    memcpy(&eth->eth_dst, dhcp_data->chaddr, sizeof(eth->eth_dst));
    /* Handle broadcast flag */
    ovs_be32 ip_dst;
    if (!is_dhcp_flags_broadcast(dhcp_data->flags)) {
    ip_dst = dhcp_data->yiaddr;
    } else {
    ip_dst = htonl(0xffffffff);
    }
    put_16aligned_be32(&out_ip->ip_dst, ip_dst);
    out_ip->ip_csum = recalc_csum32(out_ip->ip_csum, ip_dst_orig, ip_dst);

    the destination MAC is set to the client’s hardware address (from chaddr), and the destination IP is set to either the client’s IP or broadcast, depending on the BROADCAST flag.


    ovs in-band: making sure dhcp actually works

    OVS has an in-band control mechanism that ensures management traffic isn’t blocked by flow tables. this includes DHCP traffic from the local management port.

    the DHCP output check

    the function in_band_must_output_to_local_port() (ovs/ofproto/in-band.c:233-243) checks if a packet is a DHCP reply that must reach the local port:

    bool
    in_band_must_output_to_local_port(const struct flow *flow)
    {
    return (flow->dl_type == htons(ETH_TYPE_IP)
    && flow->nw_proto == IPPROTO_UDP
    && flow->tp_src == htons(DHCP_SERVER_PORT)
    && flow->tp_dst == htons(DHCP_CLIENT_PORT));
    }

    this matches UDP from port 67 (server) to port 68 (client) — i.e., DHCP replies from a DHCP server running on the local OVS bridge.

    the DHCP request rule

    in update_rules() (ovs/ofproto/in-band.c:286-295), a rule is created to allow DHCP requests from the local port:

    if (ib->n_remotes && !eth_addr_is_zero(ib->local_mac)) {
    match_init_catchall(&match);
    match_set_in_port(&match, OFPP_LOCAL);
    match_set_dl_type(&match, htons(ETH_TYPE_IP));
    match_set_dl_src(&match, ib->local_mac);
    match_set_nw_proto(&match, IPPROTO_UDP);
    match_set_tp_src(&match, htons(DHCP_CLIENT_PORT));
    match_set_tp_dst(&match, htons(DHCP_SERVER_PORT));
    add_rule(ib, &match, IBR_FROM_LOCAL_DHCP);
    }

    the priority constant IBR_FROM_LOCAL_DHCP is defined as 180000 (ovs/ofproto/in-band.c:53):

    enum {
    IBR_FROM_LOCAL_DHCP = 180000, /* (a) From local port, DHCP. */
    IBR_TO_LOCAL_ARP, /* (b) To local port, ARP. */
    /* ... */
    };

    this high-priority rule ensures DHCP requests from the local bridge management interface are always allowed through, even if other flow rules would block them.


    control plane policing (copp) for dhcp

    OVN supports rate-limiting DHCP control plane traffic via CoPP. the CoPP protocol constants are defined in ovn/lib/copp.h:22-44:

    enum copp_proto {
    COPP_PROTO_FIRST,
    COPP_ARP = COPP_PROTO_FIRST,
    COPP_ARP_RESOLVE,
    COPP_DHCPV4_OPTS,
    COPP_DHCPV6_OPTS,
    COPP_DNS,
    /* ... */
    COPP_DHCPV4_RELAY,
    COPP_PROTO_MAX,
    };

    these map to string names in ovn/lib/copp.c:25-44:

    static char *copp_proto_names[COPP_PROTO_MAX] = {
    [COPP_DHCPV4_OPTS] = "dhcpv4-opts",
    [COPP_DHCPV6_OPTS] = "dhcpv6-opts",
    [COPP_DHCPV4_RELAY] = "dhcpv4-relay",
    /* ... */
    };

    the copp_meter_get() function (ovn/lib/copp.c:55-70) retrieves the meter name for a given protocol:

    const char *
    copp_meter_get(enum copp_proto proto, const struct nbrec_copp *copp,
    const struct shash *meter_groups)
    {
    if (!copp || proto >= COPP_PROTO_MAX) {
    return NULL;
    }
    const char *meter = smap_get(&copp->meters, copp_proto_names[proto]);
    if (meter && shash_find(meter_groups, meter)) {
    return meter;
    }
    return NULL;
    }

    when generating DHCP flows, northd attaches CoPP meters via the WITH_CTRL_METER() macro. for example, in build_dhcpv4_options_flows():

    ovn_lflow_add(lflows, op->od, S_SWITCH_IN_DHCP_OPTIONS, 100,
    ds_cstr(&match), ds_cstr(&options_action), lflow_ref,
    WITH_IO_PORT(inport->key),
    WITH_CTRL_METER(copp_meter_get(COPP_DHCPV4_OPTS,
    op->od->nbs->copp,
    meter_groups)),
    WITH_HINT(&op->nbsp->dhcpv4_options->header_));

    this means the DHCP options flow is rate-limited by the dhcpv4-opts meter if one is configured in the NB Copp table. the DHCP relay flows use COPP_DHCPV4_RELAY for rate-limiting.


    end-to-end packet walk

    DHCPv4 DISCOVER → OFFER

    setup:

    • Logical switch port sw0-port0 with DHCP options: server_id=10.0.0.1, server_mac=00:00:00:00:00:01, lease_time=3600, netmask=255.255.255.0, router=10.0.0.1
    • Port’s offer IP: 10.0.0.2

    packet: client sends DHCPDISCOVER

    Ethernet: src=aa:bb:cc:dd:ee:ff, dst=ff:ff:ff:ff:ff:ff
    IP: src=0.0.0.0, dst=255.255.255.255
    UDP: src=68, dst=67
    DHCP: op=REQUEST(1), chaddr=aa:bb:cc:dd:ee:ee
    Options: msg_type=DISCOVER(1)

    pipeline traversal:

    1. ls_in_port_secls_in_pre_acl → … → ls_in_dhcp_options (stage 28)

    2. match at priority 100:

      inport == "sw0-port0" && eth.src == "aa:bb:cc:dd:ee:ff" &&
      (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

      ✓ matches (ip4.src=0.0.0.0, ip4.dst=255.255.255.255)

    3. action executes: reg0[3] = put_dhcp_opts(offerip = 10.0.0.2, netmask = 255.255.255.0, ...); next;

    4. packet-in to ovn-controller: opcode ACTION_OPCODE_PUT_DHCP_OPTS, userdata contains encoded options

    5. pinctrl_handle_put_dhcp_opts() validates the packet, determines msg_type=DISCOVERmsg_type=OFFER, constructs the reply packet

    6. pipeline resumes at ls_in_dhcp_response (stage 29)

    7. match at priority 100:

      inport == "sw0-port0" && eth.src == "aa:bb:cc:dd:ee:ff" &&
      ip4 && udp.src == 68 && udp.dst == 67 && reg0[3]

      ✓ matches (reg0[3]=1)

    8. action executes:

      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;
    9. packet sent back to client

    response:

    Ethernet: src=00:00:00:00:00:01, dst=aa:bb:cc:dd:ee:ff
    IP: src=10.0.0.1, dst=10.0.0.2
    UDP: src=67, dst=68
    DHCP: op=REPLY(2), yiaddr=10.0.0.2
    Options: msg_type=OFFER(2), netmask=255.255.255.0, router=10.0.0.1, ...

    DHCPv4 REQUEST → ACK

    same as above, but the client sends a DHCPREQUEST with msg_type=REQUEST(3) and requested_ip=10.0.0.2. the handler validates that requested_ip == offer_ip and responds with msg_type=ACK(5).

    DHCPv4 REQUEST → NAK (wrong IP)

    if the client sends a DHCPREQUEST with requested_ip=10.0.0.99 (which doesn’t match the offer), the handler sets msg_type=NAK(6):

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

    the NAK response has yiaddr=0 and siaddr=0:

    dhcp_data->yiaddr = (msg_type == DHCP_MSG_NAK) ? 0 : *offer_ip;
    dhcp_data->siaddr = (msg_type == DHCP_MSG_NAK) ? 0 : next_server;

    and DHCP options are not included in NAK messages:

    if (msg_type != DHCP_MSG_NAK) {
    new_l4_size += reply_dhcp_opts_ptr->size;
    }

    DHCPv6 SOLICIT → ADVERT

    packet: client sends DHCPv6 SOLICIT

    Ethernet: src=aa:bb:cc:dd:ee:ff, dst=33:33:00:01:00:02
    IPv6: src=fe80::..., dst=ff02::1:2
    UDP: src=546, dst=547
    DHCPv6: msg_type=SOLICIT(1), transaction_id=...
    Options: Client_ID, IA_NA(iaid=...), ...

    pipeline traversal at ls_in_dhcp_options:

    match:

    inport == "sw0-port0" && eth.src == "aa:bb:cc:dd:ee:ff"
    && ip6.mcast && ip6.dst == ff02::1:2
    && udp.src == 546 && udp.dst == 547

    action: reg0[3] = put_dhcpv6_opts(ia_addr = fe80::..., server_id = 00:00:00:00:00:01, ...); next;

    at pinctrl_handle_put_dhcpv6_opts():

    • message type SOLICIT → response type ADVERT
    • IAID extracted from client’s IA_NA option
    • Client ID extracted and copied to response
    • options composed via compose_out_dhcpv6_opts()
    • Client ID + composed options assembled into the response packet

    DHCP relay: client request to remote server

    setup:

    • Logical switch sw0 with other_config:dhcp_relay_port = "sw0-lr0"
    • Logical router port lr0-sw0 with dhcp_relay pointing to a DHCP_Relay with servers = "192.168.1.100"
    • Router port IP: 10.0.0.1/24

    packet: client sends DHCPDISCOVER

    Ethernet: src=aa:bb:cc:dd:ee:ff, dst=ff:ff:ff:ff:ff:ff
    IP: src=0.0.0.0, dst=255.255.255.255
    UDP: src=68, dst=67
    DHCP: op=REQUEST(1), chaddr=aa:bb:cc:dd:ee:ff, giaddr=0

    pipeline traversal:

    1. ls_in_l2_lkup (stage 14): the relay flow at priority 100 matches:

      inport == "sw0-port0" && eth.src == "aa:bb:cc:dd:ee:ff" &&
      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 = <router_mac>; outport = "sw0-lr0"; next;

    2. lr_in_ip_input (stage 3): the relay request validation flow at priority 110 matches:

      inport == "lr0-sw0" &&
      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. packet-in to ovn-controller: opcode ACTION_OPCODE_DHCP_RELAY_REQ_CHK

    4. pinctrl_handle_dhcp_relay_req_chk() validates the packet:

      • op == DHCP_OP_REQUEST
      • giaddr == 0
      • message type present ✓
      • sets giaddr = 10.0.0.1 (relay IP)
      • increments hops to 1
      • updates UDP checksum
    5. lr_in_dhcp_relay_req (stage 4): the flow at priority 100 matches (reg9[7]=1):

      inport == "lr0-sw0" &&
      ip4.src == {0.0.0.0, 10.0.0.0/24} && ip4.dst == 255.255.255.255 &&
      udp.src == 68 && udp.dst == 67 && reg9[7]

      action: ip4.src = 10.0.0.1; ip4.dst = 192.168.1.100; udp.src = 67; next;

    6. the packet is now addressed to the remote DHCP server and is routed normally.

    DHCP relay: server response back to client

    packet: DHCP server sends DHCPOFFER

    Ethernet: src=<server_mac>, dst=<router_mac>
    IP: src=192.168.1.100, dst=10.0.0.1
    UDP: src=67, dst=67
    DHCP: op=REPLY(2), yiaddr=10.0.0.2, giaddr=10.0.0.1, chaddr=aa:bb:cc:dd:ee:ff

    pipeline traversal:

    1. lr_in_ip_input (stage 3): the relay response flow at priority 110 matches:

      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 (stage 20): the flow at priority 100 matches:

      ip4.src == 192.168.1.100 && ip4.dst == 10.0.0.1 &&
      udp.src == 67 && udp.dst == 67

      action: reg2 = ip4.dst; reg9[8] = dhcp_relay_resp_chk(10.0.0.1, 192.168.1.100); next;

    3. packet-in to ovn-controller: opcode ACTION_OPCODE_DHCP_RELAY_RESP_CHK

    4. pinctrl_handle_dhcp_relay_resp_chk() validates:

      • op == DHCP_OP_REPLY
      • giaddr != 0 ✓ (giaddr=10.0.0.1)
      • giaddr == relay_ip
      • message type present ✓
      • server_id present ✓
      • server_id == server_ip
      • for OFFER: (yiaddr & netmask) == (giaddr & netmask)
      • sets eth.dst = client_mac (from chaddr)
      • handles broadcast flag for ip_dst
    5. lr_in_dhcp_relay_resp (stage 21): the flow at priority 100 matches (reg9[8]=1):

      ip4.src == 192.168.1.100 && reg2 == 10.0.0.1 &&
      udp.src == 67 && udp.dst == 67 && reg9[8]

      action: ip4.src = 10.0.0.1; udp.dst = 68; outport = "lr0-sw0"; output;

    6. the packet is forwarded back to the logical switch and delivered to the client.

    that-was-a-lot-of-pipeline-stages


    access controls: the priority 34000 acl flow

    one of the most important but easily overlooked aspects of DHCP in OVN is the ACL bypass flow. without it, DHCP replies from ovn-controller would be dropped by default-deny ACLs. i spent a good while staring at “why isn’t my DHCP reply getting through?” before i found this.

    for each port with DHCP options configured, northd generates a flow at stage S_SWITCH_OUT_ACL_EVAL with priority 34000 (ovn/northd/northd.c:9967-9989):

    if (!is_external) {
    const char *server_id = smap_get(
    &op->nbsp->dhcpv4_options->options, "server_id");
    const char *server_mac = smap_get(
    &op->nbsp->dhcpv4_options->options, "server_mac");
    const char *lease_time = smap_get(
    &op->nbsp->dhcpv4_options->options, "lease_time");
    ovs_assert(server_id && server_mac && lease_time);
    const char *dhcp_actions =
    REGBIT_ACL_VERDICT_ALLOW" = 1; next;";
    ds_clear(&match);
    ds_put_format(&match, "outport == %s && eth.src == %s "
    "&& ip4.src == %s && udp && udp.src == 67 "
    "&& udp.dst == 68", op->json_key,
    server_mac, server_id);
    ovn_lflow_add(lflows, op->od, S_SWITCH_OUT_ACL_EVAL, 34000,
    ds_cstr(&match), dhcp_actions, lflow_ref,
    WITH_IO_PORT(op->key),
    WITH_HINT(&op->nbsp->dhcpv4_options->header_));
    }

    this flow matches DHCP replies (udp.src=67, udp.dst=68) from the server MAC and server IP to the specific logical port, and sets REGBIT_ACL_VERDICT_ALLOW (reg1[0]) to 1. priority 34000 is high enough to match before most user-defined ACLs but lower than the universal flows at 65532+.

    the same pattern applies for DHCPv6 (ovn/northd/northd.c:10043-10072):

    ds_put_format(&match, "outport == %s && eth.src == %s "
    "&& ip6.src == %s && udp && udp.src == 547 "
    "&& udp.dst == 546", op->json_key,
    server_mac, server_ip);
    ovn_lflow_add(lflows, op->od, S_SWITCH_OUT_ACL_EVAL, 34000,
    ds_cstr(&match), dhcp6_actions, lflow_ref, ...);

    appendix a: dhcp packet structure reference

    the DHCP packet structure is defined in ovs/lib/dhcp.h:31-49:

    OVS_PACKED(
    struct dhcp_header {
    uint8_t op; /* DHCP_BOOTREQUEST(1) or DHCP_BOOTREPLY(2). */
    uint8_t htype; /* ARP_HRD_ETHERNET (0x1). */
    uint8_t hlen; /* ETH_ADDR_LEN (0x6). */
    uint8_t hops; /* Hop count; set to 0 by client. */
    ovs_be32 xid; /* Transaction ID. */
    ovs_be16 secs; /* Since client started address acquisition. */
    ovs_be16 flags; /* DHCP_FLAGS_* (bit 15 = broadcast). */
    ovs_be32 ciaddr; /* Client IP, if it has a lease for one. */
    ovs_be32 yiaddr; /* Client ("your") IP address. */
    ovs_be32 siaddr; /* Next server IP address. */
    ovs_be32 giaddr; /* Relay agent IP address. */
    uint8_t chaddr[16]; /* Client hardware address. */
    char sname[64]; /* Optional server host name. */
    char file[128]; /* Boot file name. */
    /* Followed by variable-length options field. */
    });
    BUILD_ASSERT_DECL(DHCP_HEADER_LEN == sizeof(struct dhcp_header));

    key constants from ovs/lib/dhcp.h:24-64:

    #define DHCP_SERVER_PORT 67
    #define DHCP_CLIENT_PORT 68
    #define DHCP_MAGIC_COOKIE 0x63825363
    #define DHCP_HEADER_LEN 236
    #define DHCP_OP_REQUEST 1
    #define DHCP_OP_REPLY 2
    #define DHCP_MSG_DISCOVER 1
    #define DHCP_MSG_OFFER 2
    #define DHCP_MSG_REQUEST 3
    #define DHCP_MSG_ACK 5
    #define DHCP_MSG_NAK 6
    #define DHCP_OPT_PAD 0
    #define DHCP_OPT_REQ_IP 50
    #define DHCP_OPT_MSG_TYPE 53
    #define DHCP_OPT_END 255

    additional OVN-specific DHCP message types from ovn/lib/ovn-l7.h:247-249:

    #define OVN_DHCP_MSG_DECLINE 4
    #define OVN_DHCP_MSG_RELEASE 7
    #define OVN_DHCP_MSG_INFORM 8

    the DHCPv6 header (ovn/lib/ovn-l7.h:316-321):

    OVS_PACKED(
    struct dhcpv6_header {
    uint8_t msg_type;
    uint8_t transaction_id[3];
    });
    BUILD_ASSERT_DECL(DHCPV6_HEADER_LEN == sizeof(struct dhcpv6_header));

    the DHCP option header (ovn/lib/ovn-l7.h:236-244):

    #define DHCP_OPT_HEADER_LEN 2
    struct dhcp_opt_header {
    uint8_t code;
    uint8_t len;
    };
    #define DHCP_OPT_PAYLOAD(hdr) \
    (void *)((char *)hdr + sizeof(struct dhcp_opt_header))

    appendix b: complete action opcode reference for dhcp

    OpcodeEnum ValueUserdata FormatHandler
    ACTION_OPCODE_PUT_DHCP_OPTS(auto)[OXM][offset][offer_ip][opt1][opt2]...pinctrl_handle_put_dhcp_opts
    ACTION_OPCODE_PUT_DHCPV6_OPTS(auto)[OXM][offset][opt1][opt2]...pinctrl_handle_put_dhcpv6_opts
    ACTION_OPCODE_DHCP6_SERVER(auto)(OF1.3 actions)pinctrl_handle_dhcp6_server
    ACTION_OPCODE_DHCP_RELAY_REQ_CHK(auto)[OXM][offset][relay_ip][server_ip]pinctrl_handle_dhcp_relay_req_chk
    ACTION_OPCODE_DHCP_RELAY_RESP_CHK(auto)[OXM][offset][relay_ip][server_ip]pinctrl_handle_dhcp_relay_resp_chk

    the struct action_header (ovn/include/ovn/actions.h:854-858) is always the first 8 bytes of userdata:

    struct action_header {
    ovs_be32 opcode; /* One of ACTION_OPCODE_* */
    uint8_t pad[4];
    };

    appendix c: ovn dhcp option wire format types

    Type StringWire FormatExample
    "ipv4"4 bytes per value, network byte order10.0.0.10x0a000001
    "uint8"1 byte1280x80
    "uint16"2 bytes, network byte order15000x05dc
    "uint32"4 bytes, network byte order36000x00000e10
    "bool"1 byte (0 or 1)true0x01
    "str"Raw bytes, length = strlen"hello"0x68656c6c6f
    "host_id"String or IPv4"tftp.example.com" or 10.0.0.1
    "mac"6 bytes (ETH_ADDR_LEN)00:01:02:03:04:05 → 6 bytes
    "ipv6"16 bytes per valuefe80::1 → 16 bytes
    "domains"RFC 1035 DNS-encoded with compressionSee Section 5.4
    "static_routes"RFC 3442 classless route encodingSee Section 5.4
    "domain"FQDN encoded per RFC 3696See encode_fqdn_string()

    appendix d: source file reference

    ComponentFileKey Functions/Lines
    DHCP packet structureovs/lib/dhcp.hstruct dhcp_header (L31-49), constants (L24-64)
    OVN DHCP option definitionsovn/lib/ovn-l7.hDHCP_OPTION macros (L77-156), struct dhcp_opt_header (L236-244)
    DHCPv6 structuresovn/lib/ovn-l7.hstruct dhcpv6_header (L316-321), option structs (L326-411)
    CoPP definitionsovn/lib/copp.henum copp_proto (L22-44)
    CoPP implementationovn/lib/copp.ccopp_meter_get() (L55-70)
    Action opcodesovn/include/ovn/actions.hACTION_OPCODES macro (L663-844)
    Action structsovn/include/ovn/actions.hovnact_put_opts (L406-411), ovnact_dhcp_relay (L414-419)
    Action encodingovn/lib/actions.cencode_PUT_DHCPV4_OPTS (L3228-3292), encode_PUT_DHCPV6_OPTS (L3294-3313)
    Option encodingovn/lib/actions.cencode_put_dhcpv4_option (L3026-3192), encode_put_dhcpv6_option (L3194-3226)
    Relay encodingovn/lib/actions.cencode_dhcpv4_relay_chk (L2883-2901)
    Action parsingovn/lib/actions.cparse_put_dhcp_opts (L2976-2995), parse_dhcp_relay_chk (L2846-2881)
    NB DB schemaovn/ovn-nb.ovsschemaDHCP_Options (L711-719), DHCP_Relay (L720-731)
    SB DB schemaovn/ovn-sb.ovsschemaDHCP_Options (L305-317), DHCPv6_Options (L318-328)
    Northd option syncovn/northd/ovn-northd.csupported_dhcp_opts[] (L244-283), sync functions (L448-519)
    Northd flow generationovn/northd/northd.cbuild_dhcpv4_options_flows (L9900-9995), build_dhcpv6_options_flows (L9997-10077)
    Northd action buildingovn/northd/northd.cbuild_dhcpv4_action (L5956-6040), build_dhcpv6_action (L6042-6121)
    Northd relay flowsovn/northd/northd.cbuild_lswitch_dhcp_relay_flows (L10085-10162), build_dhcp_relay_flows_for_lrouter_port (L16815-16980)
    Northd stage defsovn/northd/northd.hPipeline stages (L548-605)
    Northd register defsovn/northd/northd.cREGBIT_DHCP_OPTS_RESULT (L126), relay registers (L209-230)
    Controller engineovn/controller/ovn-controller.cen_dhcp_options_run (L3836-3862), graph (L7075-7078)
    Controller flow parsingovn/controller/lflow.clflow_parse_actions (L365-408)
    Controller pinctrlovn/controller/pinctrl.cprocess_packet_in (L3746-3919), pinctrl_handle_put_dhcp_opts (L2604-2944)
    Controller DHCPv6ovn/controller/pinctrl.cpinctrl_handle_put_dhcpv6_opts (L3138-3364)
    Controller relay reqovn/controller/pinctrl.cpinctrl_handle_dhcp_relay_req_chk (L2245-2388)
    Controller relay respovn/controller/pinctrl.cpinctrl_handle_dhcp_relay_resp_chk (L2390-2600)
    Controller packet parsingovn/controller/pinctrl.cdhcp_get_hdr_from_pkt (L2112-2168), dhcp_parse_options (L2180-2243)
    Controller DHCPv6 PDovn/controller/pinctrl.cpinctrl_handle_dhcp6_server (L1155-1184)
    OVS in-band DHCPovs/ofproto/in-band.cin_band_must_output_to_local_port (L233-243), rule creation (L286-295)
    OVS DHCP constantsovs/lib/dhcp.hAll constants and structs
    NB CLIovn/utilities/ovn-nbctl.cDHCP CLI commands