pebble.
Pebble / Docs / Networking

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:

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):

  1. macros and constant tables (<pebble_internal>, <pebble_nat_src>, <pebble_wgN>);
  2. set block-policy drop, set skip on lo0, scrub in all;
  3. 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);
  4. 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;
  5. block all first, so there is no default-pass window, then quick rules: 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:

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:

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.