Skip to main content

IoT VLAN 94 — Segmentation, Kea DHCP, Firewall Rules, and Device Migration

Summary

TheBurrow's flat 192.168.95.0/24 network had no segmentation — smart home devices, 3D printers, cameras, and Roombas shared the same broadcast domain as servers, Pis, and the management network. IoT devices have a poor security track record: weak firmware, unencrypted protocols, and cloud dependencies that make them attractive pivot points.

VLAN 94 (192.168.94.0/24) was built as the first segmented zone in a planned nine-VLAN architecture. This lab note documents the full implementation: VLAN design, Kea DHCP, OPNsense firewall rules, DNS enforcement, Omada SSID tagging, and the device migration process.


Architecture

VLAN design

VLANSubnetPurpose
94192.168.94.0/24IoT — smart devices, cameras, printers
95192.168.95.0/24LAN — servers, Pis (legacy, migration pending)

Gateway for IoT: 192.168.94.1 (nevermore igc1.94 subinterface) DNS for IoT: 192.168.95.250, 192.168.95.251 (Pi-hole VIPs via NAT)

Trust model

IoT devices on VLAN 94 can:

  • Reach the internet (via NAT on nevermore)
  • Reach DNS (forced through Pi-hole cluster via NAT redirect)
  • Reach Home Assistant on fablehaven (explicit allow rule)

IoT devices cannot:

  • Initiate connections to LAN (192.168.95.0/24)
  • Bypass DNS enforcement (port 53 redirected, DoT port 853 blocked)
  • Reach each other on the IoT subnet (no intra-VLAN rules added yet)

Wireless isolation

TheBurrow-IoT SSID on Omada is tagged to VLAN 94, 2.4 GHz only. IoT devices connect to this SSID and receive 192.168.94.x addresses from Kea. The main TheBurrow SSID remains on the untagged LAN.


Notes

OPNsense VLAN interface

Created VLAN 94 interface on nevermore via Interfaces → Other Types → VLAN:

  • Parent: igc1 (LAN physical interface)
  • VLAN tag: 94
  • Description: IoT

Assigned the VLAN interface as igc1.94 and configured:

  • IPv4: Static, 192.168.94.1/24
  • IPv6: Disabled
  • Block private networks: Disabled (it is a private network)

Kea DHCP subnet

Added IoT subnet to Kea via OPNsense Services → Kea DHCP → Subnets:

{
"subnet": "192.168.94.0/24",
"interface": "igc1.94",
"pools": [{ "pool": "192.168.94.100-192.168.94.199" }],
"option-data": [
{ "name": "routers", "data": "192.168.94.1" },
{ "name": "domain-name-servers", "data": "192.168.95.250, 192.168.95.251" }
]
}

Dynamic pool: .100–.199 (100 addresses) Static reservations: below .100

DNS option-data serves the Pi-hole VIPs (.250, .251), not raw Pi-hole IPs — consistent with the VRRP topology design.

Static reservations

31 IoT devices migrated to static reservations. Each reservation:

{
"hw-address": "4c:b9:ea:08:3f:a2",
"ip-address": "192.168.94.100",
"hostname": "winky"
}

Devices were identified by cross-referencing:

  1. OPNsense DHCP lease table (existing LAN leases)
  2. Home Assistant device registry (for friendly names)
  3. Physical inspection for unlabeled devices

Home Assistant WebSocket API for friendly names

The HA WebSocket API provided the cleanest mapping from MAC address to friendly name. Connection pattern:

import websocket
import json

ws = websocket.WebSocket()
ws.connect("ws://192.168.95.6:8123/api/websocket")

## Authenticate
ws.send(json.dumps({
"type": "auth",
"access_token": vault_secrets_homeassistant.long_lived_token
}))

## Fetch device registry
ws.send(json.dumps({
"id": 1,
"type": "config/device_registry/list"
}))

The response includes connections arrays with MAC addresses and name_by_user for friendly names. Cross-referenced against the DHCP lease table to build the reservation list.

CSV import workflow

Reservations were built as a CSV, edited in Numbers, and imported to Kea via a Python script using the OPNsense Kea REST API:

hostname,mac,ip
winky,4c:b9:ea:08:3f:a2,192.168.94.100
warden-luccio,xx:xx:xx:xx:xx:xx,192.168.94.70
...

The import script called: POST /api/kea/dhcpv4/addReservation for each row.

After import, applied the configuration: POST /api/kea/service/reconfigure

OPNsense firewall rules for IoT VLAN

Rules applied to the IoT interface (igc1.94), evaluated top-to-bottom:

ActionProtocolSourceDestinationPortDescription
AllowTCP/UDPIoT netPi-hole VIPs53, 8080DNS and Pi-hole API
AllowTCPIoT netfablehaven8123Home Assistant
AllowTCP/UDPIoT netany80, 443Internet HTTP/HTTPS
AllowUDPIoT netany123NTP
BlockanyIoT netRFC1918anyBlock LAN access
AllowanyIoT netanyanyInternet egress

Order matters: the RFC1918 block must precede the internet allow rule.

DNS enforcement NAT rules

Five NAT port-forward rules added for the IoT interface, mirroring the existing LAN rules:

## Redirect DNS to Pi-hole VIP
IoT net → any:53 → redirect to 192.168.95.250:53

## Block DNS-over-TLS
IoT net → any:853 → block

## Redirect DNS-over-HTTPS candidates
IoT net → any:443 (DoH hosts) → block

The no rdr exclusion pattern exempts nevermore (192.168.95.1) and the Pi-hole nodes themselves from DNS redirection — same pattern as the LAN interface rules.

Omada SSID configuration

TheBurrow-IoT SSID configured in Omada (Coruscant controller):

  • Band: 2.4 GHz only (IoT devices rarely support 5 GHz)
  • VLAN: 94 (tagged)
  • Security: WPA2-Personal
  • Client isolation: Enabled (clients cannot reach each other)
  • Band steering: Disabled

The SSID is active on all three EAP720 APs but serves only 2.4 GHz clients. 5 GHz associations are rejected.

Exception: Vivint smart panel requires 5 GHz WiFi and cannot use TheBurrow-IoT. It remains on the main TheBurrow SSID (currently LAN, pending VLAN 95 Trusted cutover).

NetBox IoT device import

IoT devices were imported to NetBox via pynetbox after the VLAN was live. A Python script using the netbox-tools pynetbox environment created device records with:

  • Site: the-burrow
  • Location: by room (living room, kitchen, etc.)
  • Device role: iot-device
  • IP address: from Kea reservation
  • Custom field: vlan: 94

Import executed via: ~/src/netbox-tools/.venv/bin/python scripts/import_iot_devices.py


Insights

Kea DHCP requires explicit interface binding

The Kea subnet must specify the interface field matching the VLAN subinterface name (igc1.94). Without this, Kea does not know which interface to listen on for DHCP requests from the new subnet. The subnet will be configured but DHCP discovery packets will not be answered.

OPNsense FreeBSD shell limitations

OPNsense runs on FreeBSD with csh as the default shell. Standard bash heredocs and sed -i quoting patterns do not work. For any Kea configuration that cannot be done via the UI, use the REST API directly. Do not attempt to edit config.xml or Kea JSON files via shell scripts on nevermore.

Firewall rule order is security-critical

The RFC1918 block rule must appear before the general internet allow rule. OPNsense evaluates rules top-to-bottom, first match wins. An IoT device attempting to reach 192.168.95.x would otherwise match the general allow rule and succeed.

DNS option-data must serve VIPs not raw IPs

The Kea domain-name-servers option must serve the Pi-hole VIPs (.250, .251), never the raw Pi-hole host IPs (.3, .6, .158). This ensures DNS traffic passes through the VRRP layer and failover works correctly. Serving raw IPs bypasses VRRP entirely.

Device discovery is the hard part

The technical implementation of VLAN 94 took a few hours. Identifying all 31 IoT devices, mapping MAC addresses to friendly names, and deciding which devices belonged on IoT vs. LAN took longer. The HA device registry was the most useful source — it had friendly names and MAC addresses for everything that had ever touched the network.


Results

  • IoT VLAN 94 live with 31 device reservations
  • All IoT devices receiving 192.168.94.x addresses from Kea
  • DNS enforcement active: port 53 redirected to Pi-hole VIPs
  • TheBurrow-IoT SSID operational on all three EAP720 APs
  • IoT devices blocked from reaching LAN (192.168.95.0/24)
  • Home Assistant reachable from IoT (explicit allow rule)
  • NetBox updated with IoT device inventory
  • Dobby (deceased Roomba) reservation pending removal from Kea

  • newcomb-labs/engineering-journal-kb #453 — ADR: IoT VLAN segmentation strategy
  • newcomb-labs/engineering-journal-kb #451 — ADR: No RDR exclusion rule
  • newcomb-labs/opnsense-lab #19 (IoT VLAN isolation issue)
  • mistborn-ops/ansible #280 (IoT VLAN design issue)
  • newcomb-labs/engineering-journal-kb #459 (this issue)