All writeups

pfSense as the Lab's Perimeter and Segmentation Engine

March 22, 2026

pfSenseNetwork SecuritySuricataVLANFirewallWireGuard

Every other service in the lab assumes the network underneath it is segmented and controlled. pfSense is what makes that true. It's the perimeter, the router between VLANs, the IPS, the VPN concentrator, and, importantly, the thing Wazuh calls to block an attacker in real time. This is how it's built and why.

Design principle: segment first, default-deny always

The single most valuable thing pfSense does here isn't blocking the internet: it's controlling traffic between internal segments. A flat network means one compromised host can reach everything. Segmentation plus default-deny means a compromised IOT device can't even see the server VLAN, let alone pivot to it.

Every VLAN interface starts with an implicit deny and I add only the flows that must exist. If I can't name why two segments should talk, they don't.

VLAN layout

  VLAN 10  MGMT      hypervisor mgmt, pfSense, Wazuh dashboard   (most restricted)
  VLAN 20  SERVERS   Docker hosts, MISP, internal services
  VLAN 30  DETECT    Wazuh manager/indexer, honeypot collector
  VLAN 40  DMZ       public-facing reverse proxy, KASM
  VLAN 50  IOT       untrusted devices                          (internet-only, no lateral)
  VLAN 60  LAB       throwaway/malware-analysis, no egress by default

The rules that matter are the inter-VLAN ones. A few representative examples:

  • IOT → anywhere internal: denied. IOT gets internet and nothing else.
  • SERVERS → DETECT on 1514/tcp only: agents reach the Wazuh manager, nothing more.
  • MGMT → all: administration reaches everywhere; nothing reaches back into MGMT except established/return traffic.
  • LAB → egress: denied by default. Malware analysis happens with no way out.

Firewall rules: alias-driven, not IP-driven

I never write raw IPs into rules. Everything is an alias: hosts, ports, and the dynamic blocklist. This keeps the ruleset readable and, crucially, lets automation manipulate a blocklist without rewriting rules:

# alias: wazuh_block   (type: Host(s), initially empty)
# floating rule, evaluated first, on all interfaces:
#   Action: Block   Source: wazuh_block   Destination: any   Log: yes

That empty wazuh_block alias is the hook the SIEM plugs into (below).

Suricata inline IPS

Suricata runs inline (IPS mode) on the WAN and DMZ interfaces so it can drop, not just alert. I run the ET Open ruleset plus a few custom rules, with a tuned suppression list so legitimate lab traffic isn't dropped:

  • WAN: inline IPS, block on ET drop/malware categories.
  • DMZ: inline IPS in front of the public reverse proxy.
  • Internal VLANs: IDS mode (alert-only): I want visibility on east-west traffic without risking dropping legitimate inter-service calls.
Gotcha #1: inline IPS + hardware offload = corrupted sessions. Suricata inline mode does not get along with NIC hardware checksum/segmentation offload. Disable Hardware Checksum Offloading, TCP Segmentation Offloading, and Large Receive Offloading in pfSense, or you'll chase phantom "connection resets" that only happen under load. This one cost me a whole evening before I found the offload setting.
Gotcha #2: start Suricata in IDS, promote to IPS later. Turning on inline blocking with an untuned ruleset is how you take down your own services. I ran every interface in alert-only mode first, built the suppression list from what would have been dropped, and only then flipped to inline.

Remote access with WireGuard

I replaced OpenVPN with WireGuard for remote access: dramatically simpler config, faster handshake, and a smaller attack surface. VPN clients land in a dedicated VPN VLAN that is itself firewalled, so a connected client only reaches the specific services it needs, not the whole network:

[Interface]
Address = 10.90.0.1/24
ListenPort = 51820
PrivateKey = <server-key>

[Peer]                     # my laptop
PublicKey = <peer-key>
AllowedIPs = 10.90.0.2/32

The VPN interface gets its own ruleset: reach MGMT for administration, reach specific service ports, and nothing else. A stolen VPN key shouldn't equal full network access.

Closing the loop: Wazuh drives the firewall

This is the piece that makes the whole lab feel alive. Wazuh's active-response script authenticates to the pfSense API and adds an attacker's IP to the wazuh_block alias. The floating block rule (evaluated first) drops them instantly:

# pf-block.sh: called by Wazuh active response
curl -sk -u "$PF_API_KEY:$PF_API_SECRET" \
  -X POST "https://pfsense.lab/api/v1/firewall/alias/entry" \
  -d "{\"name\":\"wazuh_block\",\"address\":\"$1\"}"
curl -sk -u "$PF_API_KEY:$PF_API_SECRET" \
  -X POST "https://pfsense.lab/api/v1/firewall/apply"

A honeypot hit (detected by OpenCanary) or a lateral-movement pattern now results in a firewall block within seconds, and Wazuh removes the entry when the response timeout expires.

Gotcha #3: remember the apply step. The pfSense API adds the alias entry but the change isn't live until you call /firewall/apply. My first version added IPs to the blocklist that never actually took effect: the alias filled up, the attacker sailed through, and everything looked correct in the config. Always apply.

Why it's built this way

  • Segmentation is the highest-leverage control I have. It turns a single compromise from "game over" into "contained to one VLAN."
  • Alias-driven rules make automation safe. The SIEM manipulates a blocklist, never the ruleset itself, so there's no way for automation to accidentally rewrite policy.
  • Default-deny east-west is annoying to set up and worth every minute the first time something in the IOT or LAB VLAN starts behaving badly and simply can't reach anything that matters.

What's next

  • Move internal Suricata interfaces from IDS to selective inline IPS now that the suppression list is mature.
  • Export pfSense flow logs to Wazuh for east-west traffic baselining.
  • Automate WireGuard peer provisioning so adding a device is one command, not a manual key dance.