TL;DR

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env python3
"""Encode classless routes (RFC 3442) for DHCP option 121."""
import ipaddress

def encode(routes):
    out = bytearray()
    for dest, via in routes:
        net = ipaddress.ip_network(dest, strict=False)
        n = (net.prefixlen + 7) // 8                 # significant destination octets
        out.append(net.prefixlen)
        out += net.network_address.packed[:n]
        out += ipaddress.ip_address(via).packed
    return bytes(out)

routes = [
    ("192.0.2.0/24", "192.168.1.254"),   # the other site, through the VPN router
    ("0.0.0.0/0",    "192.168.1.1"),     # the default route: mandatory when you send 121
]
raw = encode(routes)
print("hex      ", raw.hex())
print("mikrotik ", "0x" + raw.hex())
print("isc      ", ", ".join(str(b) for b in raw))
print("pfsense  ", ":".join(f"{b:02x}" for b in raw))
1
2
3
4
hex       18c00002c0a801fe00c0a80101
mikrotik  0x18c00002c0a801fe00c0a80101
isc       24, 192, 0, 2, 192, 168, 1, 254, 0, 192, 168, 1, 1
pfsense   18:c0:00:02:c0:a8:01:fe:00:c0:a8:01:01

The format

Every route in option 121 is three fields glued together, no separators:

FieldSizeExample 192.0.2.0/24 via 192.168.1.254
Prefix length1 octet18 (24)
Destination, significant octets only: ⌈prefix / 8⌉0 to 4 octetsc0 00 02 (192.0.2)
Router4 octetsc0 a8 01 fe

A /24 carries three destination octets, a /16 two, a /32 four, and the default route /0 none: 00 followed directly by the router. Several routes are concatenated; the whole option fits in 255 octets (about 28 /24 routes).

The rule that breaks the network

RFC 3442, section 3: “If the DHCP server returns both a Classless Static Routes option and a Router option, the DHCP client MUST ignore the Router option.” If you start sending 121 with a single route to the other site, clients that understand it lose the gateway from option 3 and are left with no way out to the Internet. The default route goes inside 121, always, as in the example. Clients that do not understand 121 keep using option 3, so leave that configured too.

Where it goes

ISC dhcpd (dhcpd.conf): the option has to be declared, and it is worth sending it as 249 as well, which is what older Windows ask for:

1
2
3
4
5
6
7
8
9
option rfc3442-classless-static-routes code 121 = array of unsigned integer 8;
option ms-classless-static-routes code 249 = array of unsigned integer 8;

subnet 192.168.1.0 netmask 255.255.255.0 {
  range 192.168.1.100 192.168.1.199;
  option routers 192.168.1.1;
  option rfc3442-classless-static-routes 24, 192,0,2, 192,168,1,254, 0, 192,168,1,1;
  option ms-classless-static-routes     24, 192,0,2, 192,168,1,254, 0, 192,168,1,1;
}

Kea (2.4 or later) understands the option by name with a readable notation, no hex:

1
2
3
4
"option-data": [
  { "name": "classless-static-route", "code": 121,
    "data": "192.0.2.0/24 - 192.168.1.254, 0.0.0.0/0 - 192.168.1.1" }
]

pfSense / OPNsense: with the ISC backend, Services → DHCP Server → Additional BOOTP/DHCP Options, number 121, type String, value in colon-separated hex (18:c0:00:02:…); add another with 249 for Windows. With the Kea backend, option 121 is not in the UI yet (as of this post there are open requests in both projects); if your version allows custom Kea options, use the JSON above; if not, that is a reason to stay on ISC.

MikroTik RouterOS:

1
2
3
4
/ip dhcp-server option add name=classless code=121 value=0x18c00002c0a801fe00c0a80101
/ip dhcp-server option add name=classless-ms code=249 value=0x18c00002c0a801fe00c0a80101
/ip dhcp-server option sets add name=routes options=classless,classless-ms
/ip dhcp-server network set [find address=192.168.1.0/24] dhcp-option-set=routes

Which clients understand it

ClientOption 121
Windows (Vista and later)Yes; also accepts 249
Linux with NetworkManager or systemd-networkdYes, nothing to configure
Linux with dhclientOnly if rfc3442-classless-static-routes is in the request list of dhclient.conf and a hook installs the routes (Debian and Ubuntu ship one in /etc/dhcp/dhclient-exit-hooks.d/)
macOS and iOSYes
AndroidHistorically ignores it; do not count on it
FreeBSD dhclientNot without a custom dhclient-script; dhcpcd does

Check on the client with ip route (Linux), route print (Windows) or netstat -rn (macOS) after renewing the lease.

Traps

  • A /32 with a router in another subnet: valid in the encoding, but the client needs a prior route to that router; order routes from most to least specific and put the local segment’s first if needed.
  • Routes to networks the DHCP server does not control (a DHCP server on one VLAN advertising routes through another) are accepted anyway: 121 is a list of bytes, nobody validates it. A one-octet mistake becomes a route to a made-up network.
  • Clients do not revisit routes until they renew the lease; after changing the option, T1 (half the lease) is how long they take to see it, or force a renewal.
  • Windows and 249: if you only send 121, modern Windows uses it; 249 is for compatibility and carries exactly the same bytes.
  • /0 advertisements from a rogue DHCP: any DHCP server on the LAN can rewrite every client’s default route with 121; DHCP snooping on the switch is the only real defense.

Reference: RFC 3442.

See also