440 Configure DHCP and pf

Use case

Use the role vbotka.freebsd.dhcp to configure DHCP. Use the role vbotka.freebsd.pf to configure pf. Redirect the ports from the local network to the SSH in the jails.

Tree

shell> tree .
.
├── ansible.cfg
├── files
│   └── pf-rdr-ssh.conf
├── host_vars
│   └── iocage_05
│       ├── dhcp.yml
│       └── pf.yml
├── iocage.ini
├── pb-dhcp.yml
├── pb-pf-setup.yml
└── pb-pf.yml

Synopsis

  • The Ansible controller connects the iocage host iocage_05 at the wlan interface configured in /etc/rc.conf

    gateway_enable="YES"
    defaultrouter="172.16.0.1"
    cloned_interfaces="bridge0"
    ifconfig_bridge0="inet 10.10.99.1/24"
    wlans_iwm0="wlan0"
    create_args_wlan0="country US"
    ifconfig_wlan0="WPA SYNCDHCP"
    dhcpd_ifaces="bridge0"
    
  • In the playbook pb-dhcp.yml at iocage_05 configure:

    • subnet 10.10.99.0/24

    • routers [10.10.99.1]

    • range 100-200

  • In the playbook pb-pf.yml at iocage_05 configure:

    • blacklistd, fail2ban, and sshguard

    • nat

    • log all blocked

    • allow SSH from the external network

    • pass from localnet and jails’ vnet to any

    • redirect ssh ports to jails

Requirements

  • root privilege in the managed nodes.

Notes

  • In this example, a cheap HW connected via WiFi is used for the iocage server testing.

  • Change this to fit the configuration to your needs:

    • /etc/rc.conf :

      • defaultrouter

      • ifconfig_bridge0

    • dhcp.yml :

      • bsd_dhcpd_subnet

      • bsd_dhcpd_subnet_from

      • bsd_dhcpd_subnet_to

      • bsd_dhcpd_routers

    • pf.yml :

      • pf_local_net

      • pf_jail_net

      • pf_ssh_redirected_ports

Note

vbotka.freebsd.dhcp is the role dhcp in the collection vbotka.freebsd.
vbotka.freebsd_dhcp is the role freebsd_dhcp in the namespace vbotka.
vbotka.freebsd.pf is the role pf in the collection vbotka.freebsd.
vbotka.freebsd_pf is the role freebsd_pf in the namespace vbotka.
Please make sure the versions are the same before you switch between them.

See also

ansible.cfg

[defaults]
gathering = explicit
callback_result_format = yaml
display_skipped_hosts = false

[connection]
pipelining = true

Inventory iocage.ini

iocage_05 ansible_host=handy

[iocage]
iocage_05

[iocage:vars]
ansible_user=admin
ansible_become=true
ansible_python_interpreter=auto_silent

files

files/pf-rdr-ssh.conf
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2200 -> 10.10.99.100 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2201 -> 10.10.99.101 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2202 -> 10.10.99.102 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2203 -> 10.10.99.103 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2204 -> 10.10.99.104 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2205 -> 10.10.99.105 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2206 -> 10.10.99.106 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2207 -> 10.10.99.107 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2208 -> 10.10.99.108 port 22
rdr on $ext_if proto tcp from $localnet to ($ext_if) port 2209 -> 10.10.99.109 port 22

Note

  • The above listing is limited to the first 10 lines.

  • See the playbook pb-pf-setup.yml below how to create the file.

host_vars

host_vars/iocage_05/dhcp.yml
---
bsd_dhcpd_install: false

bsd_dhcpd_enable: true
bsd_dhcpd_debug: false
bsd_dhcpd_sanity: true
bsd_dhcpd_sanity_fatal: true
bsd_dhcpd_backup_conf: true

# rc.conf
bsd_dhcpd_rcconf:
  - name: dhcpd_ifaces
    value: bridge0

# Template
bsd_dhcpd_template: dhcpd.conf-minimal.j2

# Gobal
bsd_dhcpd_domain_name: g3.example.com
bsd_dhcpd_domain_name_servers: [1.1.1.1, 8.8.8.8]
bsd_dhcpd_default_lease_time: 600
bsd_dhcpd_max_lease_time: 7200

# Primary server
bsd_dhcpd_authoritative: true

# Subnet
bsd_dhcpd_subnet: 10.10.99
bsd_dhcpd_netmask: 255.255.255.0
bsd_dhcpd_subnet_from: 100
bsd_dhcpd_subnet_to: 200
bsd_dhcpd_routers: [10.10.99.1]
bsd_dhcpd_subnet_broadcast: 255

# EOF
host_vars/iocage_05/pf.yml
---
pf_install: false

pf_enable: true
pf_blacklistd: true
pf_fail2ban: true
pf_relayd: false
pf_sshguard: true

pf_blacklistd_enable: true
pf_fail2ban_enable: true
pf_sshguard_enable: true

pf_debug: false
pf_backup_conf: true

pfconf_only: false
pfconf_validate: true

# /etc/pf.conf
pf_type: default3
pf_log_enable: true
pf_ext_if: wlan0
pf_int_if: bridge0
pf_log_all_blocked: log
pf_pass_icmp_types: [echoreq, unreach]
pf_pass_icmp6_types: [echoreq, unreach]
pf_local_net: 172.16.0.0/24
pf_jail_net: 10.10.99.0/24
pf_tcp_services: [ssh]
pf_ssh_redirected_ports: "2200:2299"

# macros
pf_macros:
  ext_if: "{{ pf_ext_if }}"
  int_if: "{{ pf_int_if }}"
  localnet: "{{ pf_local_net }}"
  jailnet: "{{ pf_jail_net }}"
  logall: "{{ pf_log_all_blocked }}"
  icmp_types: "{{ pf_pass_icmp_types }}"
  icmp6_types: "{{ pf_pass_icmp6_types }}"
  tcp_services: "{{ pf_tcp_services }}"
  ssh_redirected_ports: "{{ pf_ssh_redirected_ports }}"

# includes
pf_includes:
  pf_translation: pf-rdr-ssh.conf

# pf blocks
pf_tables:
  - table <sshabuse> persist

pf_options:
  - set skip on lo0
  - set block-policy return
  - set loginterface $ext_if

pf_normalization:
  - scrub in on $ext_if all fragment reassemble

pf_translation:
  - "{{ pf_rules_rdr }}"
  - nat on $ext_if from $localnet to any -> ($ext_if)
  - nat on $ext_if from $jailnet to any -> ($ext_if)
  - nat on $int_if from $jailnet to any -> ($int_if)

pf_filtering:
  - antispoof for $ext_if
  - "{{ pf_anchors }}"
  - block $logall all
  - pass inet proto icmp icmp-type $icmp_types
  - pass inet6 proto icmp6 icmp6-type $icmp6_types
  - pass in on $ext_if proto tcp from $localnet to any port $tcp_services flags S/SA keep state
  - pass in on $ext_if proto tcp from $localnet to any port $ssh_redirected_ports flags S/SA keep state
  - pass from { self, $localnet, $jailnet } to any keep state

# blacklistd
pf_blacklistd_conf_remote: []
pf_blacklistd_conf_local:
  - {adr: ssh, type: stream, proto: '*', owner: '*', name: '*', nfail: '3', disable: 24h}
  - {adr: ftp, type: stream, proto: '*', owner: '*', name: '*', nfail: '3', disable: 24h}
  - {adr: smtp, type: stream, proto: '*', owner: '*', name: '*', nfail: '3', disable: 24h}
  - {adr: smtps, type: stream, proto: '*', owner: '*', name: '*', nfail: '3', disable: 24h}
  - {adr: submission, type: stream, proto: '*', owner: '*', name: '*', nfail: '3', disable: 24h}
  - {adr: '*', type: '*', proto: '*', owner: '*', name: '*', nfail: '3', disable: '60'}
pf_blacklistd_flags: '-r'
pf_blacklistd_rcconf:
  - {name: blacklistd_flags, value: "{{ pf_blacklistd_flags }}"}

# EOF

Playbook pb-dhcp.yml

- name: Test role vbotka.freebsd.dhcp
  hosts: iocage
  gather_facts: true

  roles:
    - vbotka.freebsd.dhcp

Playbook output - Install packages

(env) > ansible-playbook pb-dhcp.yml -i iocage.ini -t bsd_dhcpd_packages -e bsd_dhcpd_install=true
PLAY [Test role vbotka.freebsd.dhcp] *******************************************

TASK [Gathering Facts] *********************************************************
ok: [iocage_05]

TASK [vbotka.freebsd.dhcp : Packages: Install dhcp packages.] ******************
ok: [iocage_05] => (item=net/isc-dhcp44-server)

PLAY RECAP *********************************************************************
iocage_05                  : ok=2    changed=0    unreachable=0    failed=0    skipped=5    rescued=0    ignored=0   

Playbook output - Configure DHCP

(env) > ansible-playbook pb-dhcp.yml -i iocage.ini
PLAY [Test role vbotka.freebsd.dhcp] *******************************************

TASK [Gathering Facts] *********************************************************
ok: [iocage_05]

TASK [vbotka.freebsd.dhcp : Sanity: Collect installed packages.] ***************
ok: [iocage_05]

TASK [vbotka.freebsd.dhcp : Sanity: Set my_missing_packages] *******************
ok: [iocage_05]

TASK [vbotka.freebsd.dhcp : Rcconf: Enable and start dhcpd] ********************
ok: [iocage_05]

TASK [vbotka.freebsd.dhcp : Rcconf: Configure dhcpd.] **************************
ok: [iocage_05] => (item={'name': 'dhcpd_ifaces', 'value': 'bridge0'})

TASK [vbotka.freebsd.dhcp : Conf: Configure /usr/local/etc/dhcpd.conf from dhcpd.conf-minimal.j2] ***
ok: [iocage_05]

PLAY RECAP *********************************************************************
iocage_05                  : ok=6    changed=0    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0   

Playbook pb-pf-setup.yml

---

- name: FreeBSD pf role setup.
  hosts: localhost

  vars:

    ssh_rdr_start: 2200
    dhcp_subnet: 10.10.99
    dhcp_ip_start: 100
    stop: 100

  tasks:

    - name: Create pf-rdr-ssh.conf
      tags: pf-rdr-ssh
      ansible.builtin.copy:
        dest: "{{ playbook_dir }}/files/pf-rdr-ssh.conf"
        content: |
          {% for i in range(0, stop) %}
          rdr on $ext_if proto tcp from $localnet to ($ext_if) port {{ ssh_rdr_start + i }} -> {{ dhcp_subnet }}.{{ dhcp_ip_start + i }} port 22
          {% endfor %}

# EOF
...

Playbook output - Create files/pf-rdr-ssh.conf

(env) > ansible-playbook pb-pf-setup.yml -i iocage.ini
PLAY [FreeBSD pf role setup.] **************************************************

TASK [Create pf-rdr-ssh.conf] **************************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Playbook pb-pf.yml

- name: Test role vbotka.freebsd.pf
  hosts: iocage
  gather_facts: true

  roles:
    - vbotka.freebsd.pf

Playbook output - Install packages

(env) > ansible-playbook pb-pf.yml -i iocage.ini -t pf_packages -e pf_install=true
PLAY [Test role vbotka.freebsd.pf] *********************************************

TASK [Gathering Facts] *********************************************************
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Packages: Install packages.] *************************
ok: [iocage_05]

PLAY RECAP *********************************************************************
iocage_05                  : ok=2    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   

Playbook output - Configure pf

Firewall starting and restarting breaks the ssh connections. See the handlers for details. As a consequence, both handlers starting and reloading don’t work properly and the ssh connection will stale. Therefore, let us first configure the rules

(env) > ansible-playbook pb-pf.yml -i iocage.ini -e pf_enable=false
PLAY [Test role vbotka.freebsd.pf] *********************************************

TASK [Gathering Facts] *********************************************************
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-blacklistd: Stat /etc/rc.d/blacklistd] ********
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Blacklistd: Backup /etc/blacklistd.conf.orig] ********
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Blacklistd: Configure /etc/blacklistd.conf] **********
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-fail2ban: Stat /usr/local/etc/rc.d/fail2ban] ***
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Fail2ban: Configure /usr/local/etc/fail2ban/fail2ban.local] ***
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Fail2ban: Configure /usr/local/etc/fail2ban/jail.local] ***
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-sshguard: Stat /usr/local/etc/rc.d/sshguard] ***
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Configure sshguard.conf] *****************************
ok: [iocage_05] => (item={'key': 'BACKEND', 'value': '/usr/local/libexec/sshg-fw-pf'})
ok: [iocage_05] => (item={'key': 'WHITELIST_FILE', 'value': '/usr/local/etc/sshguard.whitelist'})

TASK [vbotka.freebsd.pf : Create directory /etc/pf] ****************************
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Copy includes files to /etc/pf] **********************
ok: [iocage_05] => (item={'key': 'pf_translation', 'value': 'pf-rdr-ssh.conf'})

TASK [vbotka.freebsd.pf : Pfconf: Configure and validate rules using template default3-pf.conf.j2] ***
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-blacklistd: Enable and start blacklistd.] *****
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-blacklistd: Configure blacklistd.] ************
ok: [iocage_05] => (item={'name': 'blacklistd_flags', 'value': '-r'})

TASK [vbotka.freebsd.pf : Rcconf-fail2ban: Enable and start fail2ban.] *********
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-fail2ban: Configure fail2ban.] ****************
ok: [iocage_05] => (item={'name': 'fail2ban_flags', 'value': ''})

TASK [vbotka.freebsd.pf : Rcconf-sshguard: Enable and start sshguard.] *********
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-pf: Disable and stop pf.] *********************
changed: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-pflog: Enable and start pflog.] ***************
ok: [iocage_05]

RUNNING HANDLER [vbotka.freebsd.pf : Disable and stop pf] **********************
changed: [iocage_05]

PLAY RECAP *********************************************************************
iocage_05                  : ok=20   changed=2    unreachable=0    failed=0    skipped=40   rescued=0    ignored=0   

Playbook output - Enable pf

(env) > ansible-playbook pb-pf.yml -i iocage.ini -t pf_rcconf_pf
PLAY [Test role vbotka.freebsd.pf] *********************************************

TASK [Gathering Facts] *********************************************************
ok: [iocage_05]

TASK [vbotka.freebsd.pf : Rcconf-pf: Enable and start pf.] *********************
changed: [iocage_05]

RUNNING HANDLER [vbotka.freebsd.pf : Start pf] *********************************
changed: [iocage_05]

PLAY RECAP *********************************************************************
iocage_05                  : ok=3    changed=2    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   

Results

isc-dhcpd status

(env) > ssh admin@handy sudo service isc-dhcpd status
dhcpd is running as pid 87526.

/usr/local/etc/dhcpd.conf

(env) > ssh admin@handy cat /usr/local/etc/dhcpd.conf
# Ansible managed

# Global options
option domain-name "g3.example.com";
option domain-name-servers 1.1.1.1, 8.8.8.8;
default-lease-time 600;
max-lease-time 7200;

# Denotes this server as the primary DHCP server for the local network
authoritative;

# Internal subnet configuration
subnet 10.10.99.0 netmask 255.255.255.0 {
  range 10.10.99.100 10.10.99.200;
  option routers 10.10.99.1;
  option broadcast-address 10.10.99.255;
}

pf status

(env) > ssh admin@handy sudo service pf status
Status: Disabled for 0 days 00:00:07          Debug: Urgent

Interface Stats for wlan0             IPv4             IPv6
  Bytes In                               0                0
  Bytes Out                              0                0
  Packets In
    Passed                          251661               32
    Blocked                              6                0
  Packets Out
    Passed                               0                0
    Blocked                         133704                0

State Table                          Total             Rate
  current entries                       24               
  searches                          741259       105894.1/s
  inserts                              640           91.4/s
  removals                             616           88.0/s
Counters
  match                                714          102.0/s
  bad-offset                             0            0.0/s
  fragment                               0            0.0/s
  short                                  0            0.0/s
  normalize                              0            0.0/s
  memory                                 0            0.0/s
  bad-timestamp                          0            0.0/s
  congestion                             0            0.0/s
  ip-option                              0            0.0/s
  proto-cksum                           30            4.3/s
  state-mismatch                         0            0.0/s
  state-insert                           0            0.0/s
  state-limit                            0            0.0/s
  src-limit                              0            0.0/s
  synproxy                               0            0.0/s
  map-failed                             0            0.0/s
  translate                              0            0.0/s

/etc/pf.conf

(env) > ssh admin@handy cat /etc/pf.conf
# Ansible managed
# template: default3-pf.conf.j2

# MACROS
ext_if = "wlan0"
int_if = "bridge0"
localnet = "172.16.0.0/24"
jailnet = "10.10.99.0/24"
logall = "log"
icmp_types = "{ echoreq, unreach }"
icmp6_types = "{ echoreq, unreach }"
tcp_services = "{ ssh }"
ssh_redirected_ports = "2200:2299"

# TABLES
table <sshabuse> persist

# OPTIONS
set skip on lo0
set block-policy return
set loginterface $ext_if

# NORMALIZATION
scrub in on $ext_if all fragment reassemble

# TRANSLATION
nat on $ext_if from $localnet to any -> ($ext_if)
nat on $ext_if from $jailnet to any -> ($ext_if)
nat on $int_if from $jailnet to any -> ($int_if)
include "/etc/pf/pf-rdr-ssh.conf"

# FILTERING
antispoof for $ext_if
anchor "blacklistd/*" in on $ext_if
anchor "f2b/*"
block $logall all
pass inet proto icmp icmp-type $icmp_types
pass inet6 proto icmp6 icmp6-type $icmp6_types
pass in on $ext_if proto tcp from $localnet to any port $tcp_services flags S/SA keep state
pass in on $ext_if proto tcp from $localnet to any port $ssh_redirected_ports flags S/SA keep state
pass from { self, $localnet, $jailnet } to any keep state

# EOF

List jails

(env) > ssh admin@handy sudo iocage list -l
+------+----------+------+-------+------+--------------+------------------------+-----+----------------+----------+
| JID  |   NAME   | BOOT | STATE | TYPE |   RELEASE    |          IP4           | IP6 |    TEMPLATE    | BASEJAIL |
+======+==========+======+=======+======+==============+========================+=====+================+==========+
| 1    | cb040eb9 | off  | up    | jail | 15.0-RELEASE | epair0b|192.168.99.100 | -   | ansible_client | no       |
+------+----------+------+-------+------+--------------+------------------------+-----+----------------+----------+
| None | dd911c4f | off  | down  | jail | 15.0-RELEASE | DHCP (not running)     | -   | ansible_client | no       |
+------+----------+------+-------+------+--------------+------------------------+-----+----------------+----------+
| None | f20ab29e | off  | down  | jail | 15.0-RELEASE | DHCP (not running)     | -   | ansible_client | no       |
+------+----------+------+-------+------+--------------+------------------------+-----+----------------+----------+

SSH to a jail

(env) > ssh -p 2200 admin@handy
Last login: Mon Apr  6 09:26:37 2026 from 192.168.99.1

FreeBSD 15.0-RELEASE (GENERIC) releng/15.0-n280995-7aedc8de6446

Welcome to FreeBSD!

Release Notes, Errata: https://www.FreeBSD.org/releases/
Security Advisories:   https://www.FreeBSD.org/security/
FreeBSD Handbook:      https://www.FreeBSD.org/handbook/
FreeBSD FAQ:           https://www.FreeBSD.org/faq/
Questions List:        https://www.FreeBSD.org/lists/questions/
FreeBSD Forums:        https://forums.FreeBSD.org/

Documents installed with the system are in the /usr/local/share/doc/freebsd/
directory, or can be installed later with:  pkg install en-freebsd-doc
For other languages, replace "en" with a language code like de or fr.

Show the version of FreeBSD installed:  freebsd-version ; uname -a
Please include that output and any error messages when posting questions.
Introduction to manual pages:  man man
FreeBSD directory layout:      man hier

To change this login announcement, see motd(5).
When netstat reports every 8 seconds, it tells traffic in bits per second:

$ netstat -I bge0 8
admin@cb040eb9:~ $ exit