Pebble
Pebble turns a small FreeBSD machine into a router that is configured from
one declarative YAML file. It is a configuration compiler and deployment
manager: it writes native FreeBSD configuration (PF, Unbound, dnsmasq,
WireGuard, rc.conf), validates it with FreeBSD's own tools, applies it
as a transaction with automatic rollback, and then gets out of the way.
The operating system performs networking. Pebble configures and manages
the operating system. Routing, firewalling, DHCP, DNS and WireGuard are
ordinary FreeBSD services that keep working (and survive reboots) without
the pebble binary. An optional agent adds a flight recorder, a terminal
dashboard and a web interface; forwarding never depends on it.
Licence: AGPL-3.0-or-later.
Project status#
Pebble runs the author's home router (FreeBSD 15.1-RELEASE, amd64), and everything below marked in use has run there, on top of unit tests, fuzzing and a simulated router. It is still young software with one production deployment: keep a console within reach when you first deploy it.
| Area | Status |
|---|---|
| Configuration schema, validation, compilation | Unit-tested and fuzzed |
pebble bootstrap, installer, rc.d scripts |
In use on FreeBSD 15.1 |
| Transactional apply, confirm, rollback, crash and boot recovery | In use; state machine and crash injection also unit-tested |
| PF ruleset, NAT, port forwards | In use; permitted/denied flows unit-tested against Pebble's PF model |
| DHCP (dnsmasq) and DNS (Unbound, DNSSEC) | In use |
| DNS blocklists | In use; local-zone vs RPZ measured on macOS |
| Firewall IP lists | In use |
| WireGuard site-to-site | In use |
| VPN provider exits, per-device routing and Internet blocking | In use |
| Live per-device traffic | In use |
| Web interface and privileged agent | In use |
Flight recorder, health checks, pebble explain outage, pebble top |
In use |
Per-device DNS lookup history (dns.query_log, newest) |
Unit-tested against real Unbound 1.24 output and checked in the simulated router; not yet deployed on the router |
| OPNsense importer | Tested against synthetic fixtures of three layouts and fuzzed; not yet tried on real exports |
| Memory, throughput, boot-time figures | Not yet measured on the target; see docs/benchmarks for macOS figures |
Changes made after a deployment reaches the router (the 2026-10-19
security audit's follow-ups among them) are verified there as they are
rolled out; docs/roadmap.md lists what each release still needs checked.
scripts/test-freebsd.sh is a complete integration suite (VNET jails, real
DHCP, NAT, DNS, WireGuard, rollback, SIGKILL recovery and restart tests) for
a disposable FreeBSD 15 VM.
What Pebble is#
- WAN (DHCP or static IPv4) and one LAN, IPv4 routing and NAT
- A default-deny PF firewall with explicit, ordered rules and port forwards
- DHCP with reservations (dnsmasq, DHCP only)
- Recursive, DNSSEC-validating DNS (Unbound) with local records, local zones and conditional forwarding — never an open resolver
- DNS-based ad and malware blocking with bounded memory and last-known-good lists
- Downloaded IP lists (for example known DNS-over-HTTPS servers or abuse lists) as PF tables, refreshed on a schedule and usable in any rule
- Routed WireGuard site-to-site VPN using the FreeBSD kernel's wg(4)
- VPN provider exits (ProtonVPN, Mullvad, …) that carry only the devices you assign to them, with a kill switch and tunnel DNS
- Per-device Internet access: the WAN, a VPN exit, or blocked
- Live upload/download rates and open connections for each device, and (opt-in) each device's DNS lookups with what was blocked
- Static routes
- Safe deployment: validation with native tools, generations, automatic
rollback that does not depend on your SSH session or the
pebbleprocess - On-demand diagnostics (
pebble status,pebble diagnose,pebble route explain, DNS and VPN tests) and a terminal dashboard (pebble top) - An optional network flight recorder that reconstructs incidents from
evidence (
pebble events,pebble explain outage) without capturing traffic; recording DNS lookups per device is a separate opt-in - An optional, unprivileged web interface with protected apply, confirm and rollback (docs/web.md)
- An importer for OPNsense
config.xmlexports with an honest migration report
What Pebble is not#
- Not a firewall distribution, and not a replacement for OPNsense or pfSense feature-for-feature
- No cloud service, telemetry, database or account with anyone; the web interface and agent are optional and off by default
- No IPv6 in schema version 1 (it is explicitly disabled and dropped)
- No multi-WAN, VLAN trunks, PPPoE, VPN road-warrior servers, IDS, traffic shaping or captive portal
Supported platforms and hardware#
- FreeBSD 15.1-RELEASE (amd64) is the reference platform; later supported
releases are intended to work. FreeBSD 15 ships
wg(8)anddrill(1)in the base system, which Pebble relies on. - Reference hardware: Fujitsu FUTRO S930 (AMD GX-424CC, 4 GB RAM, two Ethernet ports). Any amd64 machine or VM with two network interfaces works.
- Packages:
unbound,dnsmasq(installed by the installer).
Installation#
The pebble binary is self-contained: the rc.d scripts and examples are
built in. On a clean FreeBSD install, copy the binary over and run:
./pebble bootstrap # checks, pkg, unbound + dnsmasq, kldload pf/pflog/if_wg, installs itself
or, from the local console, go from a clean install to a running router in one step:
./pebble bootstrap --wan igb0 --lan igb1 --lan-address 192.168.10.1/24 \
--hostname pebble --timezone Europe/London --apply
pebble confirm
bootstrap is idempotent, --dry-run shows what it would do, and
--packages DIR installs the packages offline. It also creates Unbound's
DNSSEC trust anchor (root.key) in its chroot and sets pf_enable=YES with
sysrc. Without --apply it changes no interface or routing, loads no
firewall ruleset and starts no service; those are enabled in rc.conf and
started when a configuration is applied.
Alternatively, from a release tarball (built anywhere with
scripts/build.sh freebsd, then copied over) or a source checkout:
sha256 pebble-*-freebsd-amd64.tar.gz; cat pebble-*-freebsd-amd64.tar.gz.sha256 # digests must match
tar xzf pebble-*-freebsd-amd64.tar.gz && cd pebble-*/
sh scripts/install.sh
From source (requires Go, see go.mod):
make build && sh scripts/install.sh
Offline installation from previously fetched packages
(pkg fetch -d -o /media/pkgs unbound dnsmasq on a connected machine):
sh scripts/install.sh --offline /media/pkgs/All
The installer checks the release, architecture, disk space and privileges,
installs packages, the binary, rc.d scripts, examples and documentation, and
does not touch interfaces, routing, rc.conf or the firewall. See
docs/installation.md.
Initial configuration#
Do the first deployment from the local console, not over SSH: it replaces the firewall and interface configuration.
pebble interfaces # identify the ports (unplug/replug a cable to be sure)
pebble init --wan igb0 --lan igb1 --lan-address 192.168.10.1/24 --hostname pebble --timezone Europe/London
pebble validate
pebble plan --diff
pebble apply
pebble confirm # within the rollback window (default 120 s)
Pebble never guesses interface assignments; init refuses to run without
them. A complete two-interface example is in
examples/minimal.yaml and every setting is
documented in examples/annotated.yaml and
docs/configuration.md.
Applying changes#
Edit /usr/local/etc/pebble/pebble.yaml, then:
pebble plan --diff # what would change (nothing is modified)
pebble apply # validate natively, snapshot, apply, health-check
pebble confirm # keep it; otherwise it is rolled back automatically
Changes to networking, the firewall or WireGuard need confirmation; DNS,
DHCP and blocklist-only changes commit after passing health checks. Use
--confirm-always to require confirmation for everything, and
--rollback-after 5m to change the window.
Verifying routing#
pebble status
pebble diagnose
pebble routes --lookup 1.1.1.1
pebble firewall explain
pebble firewall test --in lan --src 192.168.10.50 --dst 1.1.1.1 --proto tcp --port 443
pebble firewall status # per-rule counters
DNS blocking#
Add HTTPS feeds under dns.blocking.lists, apply, then fetch them:
pebble dns blocklist update
pebble dns blocklist status
pebble dns blocklist test ads.example.com
pebble dns test ads.example.com
Feeds refresh on the configured interval via cron. A failed download keeps the previous copy; a list that Unbound rejects is never installed. See docs/dns-filtering.md.
Blocking traffic with downloaded IP lists#
firewall:
ip_lists:
update_interval: 24h
lists:
- name: doh
url: https://raw.githubusercontent.com/dibdot/DoH-IP-blocklists/master/doh-ipv4.txt
rules:
- {description: Block DNS-over-HTTPS bypass, action: block, interface: lan,
protocol: tcp/udp, source: lan, destination: list:doh, ports: ["443"]}
pebble apply && pebble confirm
pebble firewall iplist update # first download; cron refreshes it afterwards
pebble firewall iplist status
pebble firewall iplist test 1.1.1.1
Each list is a PF table loaded from a file; refreshes swap the table atomically without reloading the ruleset. See docs/networking.md.
Site-to-site WireGuard#
pebble vpn genkey wg0 # writes /usr/local/etc/pebble/keys/wg0.key (0600), prints the public key
# add the interface and peer (examples/site-to-site.yaml), then:
pebble apply && pebble confirm
pebble vpn status
pebble vpn test site-b
pebble vpn show site-b --endpoint router.example.net:51820 # settings for the other side
Traffic between sites is routed (never NATed) and needs explicit firewall rules. See docs/wireguard.md.
Per-device Internet access and VPN exits#
Each device with a DHCP reservation can reach the Internet through the WAN (the default), through a VPN provider exit, or not at all:
dhcp:
reservations:
- {name: laptop, mac: 02:00:00:00:00:50, address: 192.168.10.50, egress: wg1} # via the VPN exit wg1
- {name: tablet, mac: 02:00:00:00:00:60, address: 192.168.10.60, egress: none} # no Internet; LAN still works
A VPN exit is a WireGuard interface with role: egress built from the
provider's configuration file (examples/vpn-egress.yaml). Nothing is
routed into it except the assigned devices; if it is down they have no
Internet access rather than falling back to the WAN. Both settings can be
changed from the web interface's Devices page, which also shows each
device's live traffic and connections. See
docs/wireguard.md and
docs/networking.md.
Monitoring and the web interface#
Both are optional. To record events and traffic history, and to watch the router from a terminal:
monitoring:
enabled: true
pebble apply && pebble confirm
pebble top # live dashboard over SSH
pebble explain outage --last 1h # what happened, and how sure Pebble is
For the web interface, create an account (there is no default password),
enable management.web and apply; see docs/web.md:
pebble web user add alice --role admin
Importing from OPNsense#
pebble import opnsense config.xml -o import --map wan=igb0,lan=igb1
less import/migration-report.md
The importer never applies anything and never copies secrets into ordinary files. Follow docs/opnsense-migration.md to migrate safely using spare hardware.
Backup and recovery#
pebble backup # /var/db/pebble/backups/pebble-<time>.tar.gz (keys excluded by default)
pebble restore backup.tar.gz # restores pebble.yaml; apply separately
pebble rollback # undo a pending deployment, or redeploy the previous generation
pebble rollback --generation 3
If a deployment is never confirmed — because SSH dropped, the firewall locked you out, or the machine rebooted — the previous configuration is restored by a detached timer, by a cron check every minute, or at boot before networking starts. See docs/recovery.md.
Upgrading#
pebble update check # reports available updates; installs nothing
pkg upgrade # packages (unbound, dnsmasq)
sh scripts/install.sh --skip-packages # from a newer Pebble release
pebble plan # check what the new version would generate
Uninstalling#
sh scripts/uninstall.sh --software # remove the tool; the router keeps working
sh scripts/uninstall.sh --disable-services # stop routing at next boot (console only)
sh scripts/uninstall.sh --config # remove Pebble's configuration and state
Building and testing#
make build # bin/pebble for this machine
make build-freebsd # bin/pebble-freebsd-amd64 (static, reproducible)
make test # unit tests: no root, no network, no FreeBSD
make lint # go vet, gofmt, sh -n (and staticcheck if installed)
make fuzz
scripts/build.sh freebsd # tested release tarball + checksum
PEBBLE_TEST_DESTRUCTIVE=yes make test-integration # on a disposable FreeBSD VM
Known limitations#
- Everything marked "not yet" in the status table above.
- IPv6 is disabled. WAN must be Ethernet with DHCP or static addressing.
- One LAN. Additional interfaces, VLANs and bridges are not managed.
default_outbound: denyis not implemented.- DHCP client host names are not registered in DNS (only reservations are).
pebble firewall testevaluates Pebble's model of its own ruleset; it does not include rules in the administrator anchor or connection state.- WireGuard endpoint host names are resolved by
wg(8); stale peers are re-resolved by cron every five minutes. - Per-device routing and blocking match a device's reserved address, so they
need a DHCP reservation; a device that changes its address (or is given a
static one by hand) escapes them. They act before
firewall.rules, so your block rules do not apply to those devices' Internet traffic. - Two VPN exits may not share a tunnel address (ProtonVPN gives every
configuration
10.2.0.2). - Live traffic counts what passes through the router; traffic between two LAN devices is not seen.
Documentation#
New here? Start with the User Guide (also as PDF and LaTeX source).
Architecture ·
Configuration ·
Installation ·
Networking ·
DNS filtering ·
WireGuard ·
Recovery ·
Web interface ·
Flight recorder and pebble top ·
Security ·
OPNsense migration ·
Troubleshooting