| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Appliance Recipe: Building Automation IoT Gateway

A quad-NIC kldload appliance that captures unencrypted BACnet and Modbus traffic from building automation devices (HVAC pumps, air handlers, heat exchangers), secures it with WireGuard, and delivers it to a RabbitMQ message queue for downstream processing.

The problem: Building automation devices broadcast unencrypted UDP (BACnet on port 47808, Modbus on TCP 502). These protocols were designed in the 1990s for isolated serial networks. They have zero encryption, zero authentication. Connecting them to anything outside the building is a security nightmare.

The solution: A kldload gateway sits on the BACnet/Modbus network, captures the data, and tunnels it encrypted over WireGuard to a collection server. The building’s OT network never touches the internet directly.

This is a real-world recipe from building automation — not a hypothetical. BACnet and Modbus are everywhere: HVAC systems, chillers, pumps, variable frequency drives, lighting controls. They’re in every commercial building built since 1995. The protocols have zero security because they were designed for isolated serial networks. Connecting them to IP networks (which everyone does now) exposes them to the entire network. The kldload gateway solves this by being the only device with a foot in both worlds: one NIC on the OT network (BACnet/Modbus), one NIC on the IT network (management), and a WireGuard tunnel for encrypted data backhaul. The nftables rules hard-block OT↔IT cross-traffic. The OT devices can’t reach the internet. The internet can’t reach the OT devices. Data flows one way: OT → gateway → WireGuard → collection server. This is the ICS/SCADA security architecture that compliance frameworks (NIST 800-82, IEC 62443) require.

Architecture

Building Network                          Data Center / Cloud
┌────────────────────┐                   ┌──────────────────────┐
│  AHU-1 (BACnet)    │                   │                      │
│  Pump-3 (Modbus)   │──eth1──┐          │  RabbitMQ            │
│  VFD-7 (Modbus)    │        │          │  InfluxDB / Grafana  │
│  Chiller (BACnet)  │        │          │                      │
└────────────────────┘        │          └──────────┬───────────┘
                              │                     │
                    ┌─────────▼──────────┐          │
                    │  kldload Gateway  │          │
                    │                    │          │
                    │  eth0: management  │          │
                    │  eth1: BACnet VLAN │          │
                    │  eth2: Modbus/OT   │          │
                    │  wg0:  encrypted   ├──────────┘
                    │        backhaul    │  WireGuard tunnel
                    └────────────────────┘  (UDP 51820)

Step 1: Install kldload

Boot the ISO, select Server profile, Debian (lightest footprint for an appliance).

Unattended:

cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=iot-gateway-01
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NET_METHOD=static
KLDLOAD_NET_IP=192.168.1.10/24
KLDLOAD_NET_GATEWAY=192.168.1.1
KLDLOAD_NET_DNS=192.168.1.1
EOF
kldload-install-target --config /tmp/answers.env

Step 2: Configure the four NICs

# eth0 — Management (IT network, SSH, updates)
cat > /etc/systemd/network/10-eth0-mgmt.network << 'EOF'
[Match]
Name=eth0

[Network]
Address=192.168.1.10/24
Gateway=192.168.1.1
DNS=192.168.1.1
EOF

# eth1 — BACnet VLAN (building automation devices)
cat > /etc/systemd/network/20-eth1-bacnet.network << 'EOF'
[Match]
Name=eth1

[Network]
Address=10.1.1.10/24
EOF

# eth2 — Modbus/OT (field devices, RS-485 gateways)
cat > /etc/systemd/network/30-eth2-modbus.network << 'EOF'
[Match]
Name=eth2

[Network]
Address=10.2.1.10/24
EOF

# Enable systemd-networkd
systemctl enable --now systemd-networkd

# Disable reverse-path filtering on BACnet interface (required for broadcast reception)
cat > /etc/sysctl.d/99-bacnet.conf << 'EOF'
net.ipv4.conf.eth1.rp_filter=2
net.ipv4.conf.eth2.rp_filter=2
EOF
sysctl -p /etc/sysctl.d/99-bacnet.conf

Step 3: Set up WireGuard backhaul

# Generate keys
umask 077
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key

# Configure tunnel to collection server
cat > /etc/wireguard/wg0.conf << 'EOF'
[Interface]
Address = 10.99.0.1/24
ListenPort = 51820
PrivateKey = <gateway-private-key>

[Peer]
PublicKey = <collection-server-pubkey>
Endpoint = collection.example.com:51820
AllowedIPs = 10.99.0.2/32
PersistentKeepalive = 25
EOF

systemctl enable --now wg-quick@wg0
ping 10.99.0.2   # verify tunnel

The firewall rules are the most critical part of this recipe. The forward chain explicitly drops OT↔IT traffic in both directions. BACnet devices on eth1 can't reach the management network on eth0. Modbus devices on eth2 can't reach eth0. The only outbound path from OT is through the WireGuard tunnel (wg0) to the collection server. This is air-gap-equivalent security without a physical air gap. The gateway is the controlled bridge. If you're in a regulated industry (healthcare, utilities, manufacturing), this isolation pattern is what auditors look for. See the nftables Masterclass for the deep dive on per-interface policies.

Step 4: Firewall — isolate OT from IT

cat > /etc/nftables.d/iot-gateway.nft << 'NFTEOF'
table inet filter {
  chain input {
    # Management (eth0)
    iifname "eth0" tcp dport 22 accept

    # BACnet broadcast (eth1)
    iifname "eth1" udp dport 47808 accept

    # Modbus TCP (eth2)
    iifname "eth2" tcp dport 502 accept

    # WireGuard
    udp dport 51820 accept

    # WireGuard tunnel traffic
    iifname "wg0" accept
  }

  chain forward {
    # BLOCK cross-traffic between OT and IT — this is critical
    iifname "eth1" oifname "eth0" drop
    iifname "eth2" oifname "eth0" drop
    iifname "eth0" oifname "eth1" drop
    iifname "eth0" oifname "eth2" drop

    # Allow OT → WireGuard (data collection only)
    iifname "eth1" oifname "wg0" accept
    iifname "eth2" oifname "wg0" accept
  }
}
NFTEOF

systemctl reload nftables

Step 5: Install BACnet and Modbus tools

# BACnet — Python library
apt install -y python3-pip python3-venv
python3 -m venv /opt/bacnet
/opt/bacnet/bin/pip install BAC0 bacpypes3

# Modbus
apt install -y python3-pymodbus

# Traffic capture
apt install -y tshark tcpdump

# Serial tools (for RS-485 Modbus RTU adapters)
apt install -y minicom picocom setserial

Step 6: Install RabbitMQ

apt install -y erlang-nox

curl -1sLf 'https://packagecloud.io/rabbitmq/rabbitmq-server/gpgkey' | \
  gpg --dearmor > /usr/share/keyrings/rabbitmq.gpg

echo "deb [signed-by=/usr/share/keyrings/rabbitmq.gpg] https://packagecloud.io/rabbitmq/rabbitmq-server/debian/ bookworm main" \
  > /etc/apt/sources.list.d/rabbitmq.list

apt update && apt install -y rabbitmq-server

# Enable management UI + MQTT broker
rabbitmq-plugins enable rabbitmq_management
rabbitmq-plugins enable rabbitmq_mqtt

# Create a user for the collectors
rabbitmqctl add_user iot_collector changeme
rabbitmqctl set_permissions -p / iot_collector ".*" ".*" ".*"

systemctl enable --now rabbitmq-server

RabbitMQ management UI: http://10.99.0.1:15672


Step 7: BACnet collector service

#!/usr/bin/env python3
# /opt/bacnet/bacnet-collector.py
# Discovers BACnet devices, polls Present-Value, publishes to RabbitMQ

import BAC0
import pika
import json
import time
from datetime import datetime, timezone

RABBITMQ_HOST = "localhost"
RABBITMQ_USER = "iot_collector"
RABBITMQ_PASS = "changeme"
EXCHANGE = "building_data"
POLL_INTERVAL = 30  # seconds

def main():
    # Connect to BACnet network on eth1
    bacnet = BAC0.connect(ip="10.1.1.10/24")

    # Connect to RabbitMQ
    credentials = pika.PlainCredentials(RABBITMQ_USER, RABBITMQ_PASS)
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(RABBITMQ_HOST, credentials=credentials)
    )
    channel = connection.channel()
    channel.exchange_declare(exchange=EXCHANGE, exchange_type="fanout", durable=True)

    # Discover devices
    bacnet.discover()
    time.sleep(5)

    while True:
        for device in bacnet.devices:
            try:
                name = device[0]
                addr = device[1]
                dev = BAC0.device(addr, device[2], bacnet)

                for point in dev.points:
                    msg = {
                        "timestamp": datetime.now(timezone.utc).isoformat(),
                        "device": name,
                        "address": str(addr),
                        "point": point.properties.name,
                        "value": point.lastValue,
                        "units": str(point.properties.units_state),
                        "type": "bacnet",
                    }
                    channel.basic_publish(
                        exchange=EXCHANGE,
                        routing_key="",
                        body=json.dumps(msg),
                    )
            except Exception as e:
                print(f"Error polling {device}: {e}")

        time.sleep(POLL_INTERVAL)

if __name__ == "__main__":
    main()
# systemd service
cat > /etc/systemd/system/bacnet-collector.service << 'EOF'
[Unit]
Description=BACnet data collector
After=network-online.target rabbitmq-server.service
Wants=network-online.target

[Service]
Type=simple
ExecStart=/opt/bacnet/bin/python /opt/bacnet/bacnet-collector.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now bacnet-collector

Step 8: Modbus collector service

#!/usr/bin/env python3
# /opt/modbus-collector.py
# Polls Modbus TCP devices, publishes to RabbitMQ

from pymodbus.client import ModbusTcpClient
import pika
import json
import time
from datetime import datetime, timezone

RABBITMQ_HOST = "localhost"
RABBITMQ_USER = "iot_collector"
RABBITMQ_PASS = "changeme"
EXCHANGE = "building_data"
POLL_INTERVAL = 10  # seconds

# Define your Modbus devices and registers
DEVICES = [
    {
        "name": "pump-1",
        "host": "10.2.1.100",
        "port": 502,
        "registers": [
            {"name": "speed_rpm", "address": 40001, "count": 1, "scale": 1.0},
            {"name": "current_amps", "address": 40002, "count": 1, "scale": 0.1},
            {"name": "temperature_c", "address": 40003, "count": 1, "scale": 0.1},
            {"name": "run_status", "address": 40004, "count": 1, "scale": 1.0},
        ],
    },
    {
        "name": "vfd-3",
        "host": "10.2.1.101",
        "port": 502,
        "registers": [
            {"name": "frequency_hz", "address": 40001, "count": 1, "scale": 0.01},
            {"name": "output_power_kw", "address": 40002, "count": 1, "scale": 0.1},
        ],
    },
]

def main():
    credentials = pika.PlainCredentials(RABBITMQ_USER, RABBITMQ_PASS)
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(RABBITMQ_HOST, credentials=credentials)
    )
    channel = connection.channel()
    channel.exchange_declare(exchange=EXCHANGE, exchange_type="fanout", durable=True)

    while True:
        for device in DEVICES:
            client = ModbusTcpClient(device["host"], port=device["port"])
            if not client.connect():
                print(f"Cannot connect to {device['name']} at {device['host']}")
                continue

            for reg in device["registers"]:
                try:
                    result = client.read_holding_registers(
                        reg["address"] - 40001, count=reg["count"]
                    )
                    if not result.isError():
                        value = result.registers[0] * reg["scale"]
                        msg = {
                            "timestamp": datetime.now(timezone.utc).isoformat(),
                            "device": device["name"],
                            "address": f"{device['host']}:{device['port']}",
                            "point": reg["name"],
                            "value": value,
                            "type": "modbus",
                        }
                        channel.basic_publish(
                            exchange=EXCHANGE,
                            routing_key="",
                            body=json.dumps(msg),
                        )
                except Exception as e:
                    print(f"Error reading {device['name']}/{reg['name']}: {e}")

            client.close()

        time.sleep(POLL_INTERVAL)

if __name__ == "__main__":
    main()
cat > /etc/systemd/system/modbus-collector.service << 'EOF'
[Unit]
Description=Modbus data collector
After=network-online.target rabbitmq-server.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/modbus-collector.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now modbus-collector

ZFS datasets for IoT data serve a dual purpose: retention and compliance. Building automation data often has regulatory retention requirements (energy audits, environmental compliance, equipment warranty evidence). ZFS snapshots give you immutable point-in-time records — a snapshot can't be modified after creation. zstd compression on time-series data typically saves 60-80% because sensor readings are highly repetitive. The hourly replication to the collection server via syncoid over WireGuard means data leaves the building encrypted and arrives checksummed. If the gateway dies, the collection server has every reading up to the last replication.

Step 9: ZFS datasets for data retention

# Time-series data store
zfs create -o mountpoint=/srv/iot-data -o compression=zstd rpool/srv/iot-data

# RabbitMQ persistence
zfs create -o mountpoint=/var/lib/rabbitmq -o recordsize=8k rpool/var/rabbitmq

# Capture archives
zfs create -o mountpoint=/srv/captures -o compression=zstd rpool/srv/captures

# Automated snapshots of collected data
echo '0 * * * * root zfs snapshot rpool/srv/iot-data@hourly-$(date +\%Y\%m\%d-\%H)' >> /etc/crontab
echo '0 0 * * * root zfs snapshot rpool/srv/captures@daily-$(date +\%Y\%m\%d)' >> /etc/crontab

Step 10: Replicate data to collection server

On the collection server (10.99.0.2):

# Pull IoT data over WireGuard every hour
echo '15 * * * * root syncoid 10.99.0.1:rpool/srv/iot-data rpool/iot-ingestion/gateway-01' >> /etc/crontab

Verify

# Check BACnet traffic on eth1
tcpdump -i eth1 udp port 47808 -c 5

# Check Modbus connections on eth2
ss -tn | grep :502

# Check RabbitMQ queues
rabbitmqctl list_queues

# Check WireGuard tunnel
wg show wg0

# Check data replication
zfs list -t snapshot -r rpool/srv/iot-data | tail -5

Bill of materials

Component Example Cost
Quad-NIC mini PC Protectli VP2420 or Topton N5105 $200-400
USB stick (installer) Any 8GB+ USB $5
RS-485 adapter (if Modbus RTU) USB-RS485 converter $15
Total ~$250-420

Compare to a commercial building automation gateway that costs an order of magnitude more, requires annual support contracts, and has zero snapshot or replication capability.