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_05at the wlan interface configured in/etc/rc.confgateway_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.ymlatiocage_05configure:subnet 10.10.99.0/24
routers [10.10.99.1]
range 100-200
In the playbook
pb-pf.ymlatiocage_05configure: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
See also
FreeBSD Handbook Installing and Configuring a DHCP Server
FreeBSD Handbook Firewalls
FreeBSD Forum pf and bridge
Dell XPS 13 9365 15.0-RELEASE dmesg used as
iocage_05
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
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.ymlbelow how to create the file.
host_vars
---
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
---
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