Networking
Interfaces#
Pebble manages one WAN and one LAN through rc.conf:
ifconfig_igb0="DHCP" # or "inet 203.0.113.10/29"
dhclient_flags_igb0="-c /usr/local/etc/pebble/active/dhclient.conf"
ifconfig_igb1="inet 192.168.10.1/24"
defaultrouter="203.0.113.9" # static WAN only
When an interface's settings change, pebble apply runs
service netif restart <dev>, exactly as at boot.
WAN DHCP and the router's own resolver#
With local DNS enabled, the router resolves through its own Unbound
(127.0.0.1). dhclient.conf contains
supersede domain-name-servers 127.0.0.1; so a DHCP renewal never replaces
the resolver with the ISP's servers. Unbound recurses from the root servers,
so it does not depend on any WAN-supplied DNS and there is no bootstrap
loop. With a static WAN, Pebble writes /etc/resolv.conf itself.
Set network.wan.ipv4.ignore_dns: false to let DHCP manage
/etc/resolv.conf as FreeBSD normally would.
A DHCP renewal only changes the WAN address and the default route.
Pebble's static and WireGuard routes are independent of the default route,
so a changed WAN gateway cannot redirect site-to-site traffic, and the PF
rules use dynamic interface addresses (($wan_if), (self)) that follow
address changes without reloading.
Forwarding#
gateway_enable is set to NO on purpose: FreeBSD's rc.d/routing runs
before rc.d/pf, so enabling forwarding there would forward unfiltered
traffic for a moment at every boot. Instead rc.d/pebble (which runs after
PF) enables net.inet.ip.forwarding — and only if PF is actually enabled.
If PF failed to load, forwarding stays off and a warning is logged.
IPv6#
Schema version 1 supports only network.ipv6.mode: disabled:
ipv6_activate_all_interfaces="NO"(interfaces without explicit IPv6 configuration are markedIFDISABLED),ipv6_gateway_enable="NO";- PF drops all IPv6 (
block quick inet6 all) on every interface except loopback, so nothing is forwarded unfiltered even if an upstream sends router advertisements.
The schema reserves network.ipv6 so that IPv6 can be added later without
changing how existing configurations are written.
Static routes#
network:
routes:
- {destination: 10.50.0.0/16, gateway: 192.168.10.254, description: Lab}
- {destination: 172.31.0.0/16, interface: wg0}
Routes are written to /usr/local/etc/pebble/active/routes and installed by
rc.d/pebble at boot. pebble apply adds, changes and deletes only the
routes it owns; it never flushes the routing table. Note that
service routing restart flushes all routes; run service pebble routes
afterwards.
Validation understands longest-prefix matching (see configuration.md).
Firewall (PF)#
Ownership model: Pebble fully manages the main ruleset at
/usr/local/etc/pebble/active/pf.conf (pf_rules in rc.conf); your own
additions go in /usr/local/etc/pebble/pf.local.conf, loaded into the
pebble-local anchor. /etc/pf.conf is never written.
Structure of the generated ruleset (inspect with pebble firewall show):
- macros and constant tables (
<pebble_internal>,<pebble_nat_src>,<pebble_wgN>); set block-policy drop,set skip on lo0,scrub in all;- outbound NAT:
nat on $wan_if from <pebble_nat_src> to ! <pebble_internal> -> ($wan_if:0)— internal and site-to-site destinations are never translated — and, for each VPN exit,nat on wgN from <LAN> to any -> (wgN:0); - port forwards:
rdr ... tag PEBBLE_FWD_n; the matching filter rule requires that tag, so a packet addressed directly to the internal host (for example by a neighbour on the WAN segment) is not accepted; block allfirst, so there is no default-pass window, thenquickrules: IPv6 drop, anti-spoofing, WAN DHCP, SSH and web management, per-device Internet access (below), your rules, the administrator anchor, router services, port forwards, "anything else to the router", LAN to Internet, the VPN kill switches, and finally egress.
Each LAN address may hold at most firewall.max_states_per_device
connections (default 20,000; 0 removes the cap) through the
LAN-to-Internet and per-device rules (keep state (max-src-states N)), so
one misbehaving device cannot fill PF's state table (100,000 by default)
for everyone. New connections from a device over its cap are refused;
its existing ones carry on.
Loading uses pfctl -f, which replaces the ruleset atomically and keeps the
state table, so established sessions survive. If the ruleset cannot be
loaded at boot, rc.d/pf loads the generated fallback
(pf_fallback_rules_file): everything blocked except DHCP and SSH from the
LAN management networks, nothing forwarded.
Downloaded IP lists#
firewall.ip_lists declares HTTPS feeds of IPv4 addresses and prefixes.
Each becomes a PF table, <pebble_l_NAME>, which rules use as
list:NAME in source or destination:
firewall:
ip_lists:
update_interval: 24h # 1h..24h or 7d, run by cron
max_download_mb: 8
lists:
- name: doh
url: https://raw.githubusercontent.com/dibdot/DoH-IP-blocklists/master/doh-ipv4.txt
max_entries: 10000
rules:
- description: Block DNS-over-HTTPS bypass to known DoH servers
action: block
interface: lan
protocol: tcp/udp # TCP for HTTPS, UDP for HTTP/3
source: lan
destination: list:doh
ports: ["443"]
- description: Drop traffic from listed addresses (matters for port forwards)
action: block
interface: wan
source: list:abusers
destination: any
How it works:
- The ruleset declares
table <pebble_l_doh> persist file "/var/db/pebble/iplists/doh.table".pebble applycreates an empty file if the list has never been downloaded, so the ruleset always loads; apply never downloads. pebble firewall iplist update(run by cron at the configured interval) downloads each list with the same limits as DNS blocklists (HTTPS only, including redirects; size limit after decompression; timeouts), writes the table file atomically, checks it withpfctl -n -t <table> -T replace -f <file>, then swaps the live table withpfctl -t <table> -T replace -f <file>. The ruleset is not reloaded and existing connections are untouched.- A failed download, an oversized feed, a feed with no usable entries or a rejected table keeps the previous contents in force.
- Feed content is untrusted. Only IPv4 addresses and prefixes are accepted
(comments with
#or;); IPv6 entries are skipped; prefixes shorter than /8 are refused; loopback, link-local, multicast and "this network" are dropped; and entries overlapping the LAN, tunnels, peers, static routes or a static WAN are dropped, so a hostile feed cannot cut the router off from its own networks. - A
passrule that uses a list is allowed but produces a warning: whoever controls the feed then controls what the rule admits. - FreeBSD limits PF table entries to 200,000 by default; when the
configured
max_entriescould exceed that, Pebble addsset limit table-entries.
For DNS-over-HTTPS, combine this with the matching domain list in
dns.blocking.lists (doh-domains.txt from the same project), as in
examples/dns-filtering.yaml.
Commands: pebble firewall iplist update | status | test <address>.
pebble firewall test uses the downloaded lists when evaluating a flow.
Every rule has a label; pebble firewall status shows per-rule counters.
Per-device Internet access#
A device with a DHCP reservation can be given its own way to the Internet
with egress:
egress |
Generated rule (evaluated before firewall.rules) |
Effect |
|---|---|---|
| (absent) | none | the WAN, under the normal rules |
wgN (a VPN exit) |
pass in quick on $lan_if route-to (wgN <gateway>) from <device> to ! <pebble_internal> tag PEBBLE_VPN_wgN |
only through that tunnel; nothing if it is down |
none |
block in quick on $lan_if from <device> to ! <pebble_internal> |
no Internet |
Traffic to <pebble_internal> — the LAN, the router's LAN address and
WireGuard sites — never matches these rules, so DNS, printers and site-to-site
access keep working and follow the normal rules. Because the rules come
first, an administrator pass rule cannot send the device to the Internet
some other way; the other side of that is that firewall.rules block rules
do not apply to its Internet traffic either.
For each VPN exit, block out quick on $wan_if tagged PEBBLE_VPN_wgN is
the kill switch: even if the tunnel interface disappears and the kernel
would route the traffic to the WAN, it is dropped. It also comes before
your rules and the pebble-local anchor, so no pass out rule there can
let it through. A VPN exit's gateway and dns may not lie inside an
internal network (they would be reached without the tunnel), and an exit
without dns is warned about: its devices' lookups would leave through the
WAN.
The rules match the device's reserved address, so they need
dhcp.enabled: true (validation refuses egress otherwise). They are a
household control, not a security boundary against a hostile device:
- a device still on a lease from before the reservation is not affected
until it renews it, and one whose owner sets another address by hand is
not affected at all, unless
firewall.lan_internet: knownis set (see below); - a blocked device can still use the router's resolver, so DNS tunnelling remains possible;
- port forwards to the device keep working, because they are inbound.
Both settings can be changed from the Devices page of the web interface. See wireguard.md for setting up a VPN exit.
Only known addresses reach the Internet#
firewall.lan_internet: known (opt-in; the default is all) lets only the
DHCP pool and reserved addresses reach the Internet. A device that gives
itself any other address keeps the LAN and the router's services but has
no Internet access, which closes the easiest way around egress: none and
VPN-only routing. Before turning it on, give every device with a manually
set address a reservation (or move it into the pool), or it loses Internet
access. A device can still pick an unused address inside the pool; for
that, keep the pool small or use reservations for everything.