WireGuard
Site-to-site tunnels are described first; VPN provider exits, for sending chosen devices through a commercial VPN, are at the end.
Pebble configures FreeBSD's kernel wg(4) interfaces with the base system
wg(8) tool. It contains no WireGuard protocol code; key generation uses
X25519 from Go's standard library, exactly as wg genkey does.
Example: two sites#
| Site A | Site B | |
|---|---|---|
| LAN | 192.168.10.0/24 | 192.168.20.0/24 |
| Tunnel | 10.77.0.1/24 | 10.77.0.2/24 |
| Public name | a.example.net | b.example.net |
On each router:
pebble vpn genkey wg0
# Private key written to /usr/local/etc/pebble/keys/wg0.key (mode 0600, never printed).
# Public key: <copy this to the other site>
Site A (examples/site-to-site.yaml):
wireguard:
interfaces:
- name: wg0
address: 10.77.0.1/24
listen_port: 51820
private_key_file: /usr/local/etc/pebble/keys/wg0.key
peers:
- name: site-b
public_key: "<site B public key>"
endpoint: b.example.net:51820
persistent_keepalive: 25
allowed_ips: [10.77.0.2/32, 192.168.20.0/24]
routes: [192.168.20.0/24]
firewall:
rules:
- {description: Site A LAN to site B, action: pass, interface: lan, source: lan, destination: peer:site-b}
- {description: Site B to the file server, action: pass, interface: wg0, protocol: tcp, source: peer:site-b, destination: 192.168.10.5, ports: ["445"]}
Site B is the mirror image: address: 10.77.0.2/24, peer site-a with
allowed_ips: [10.77.0.1/32, 192.168.10.0/24] and
routes: [192.168.10.0/24], plus its own rules. pebble vpn show site-b --endpoint a.example.net:51820 on site A prints ready-made peer settings
for site B (public key only — never a private key).
Optional preshared key: pebble vpn genkey --psk site-b writes
keys/site-b.psk; copy the same file to the other site securely and set
preshared_key_file on both peers.
What has to match#
A packet from site A's LAN reaches site B only if all of these agree:
- Firewall on A: a rule passes LAN → site B (
allow_lan_to_wandoes not cover internal or site destinations). - Kernel route on A:
routessends 192.168.20.0/24 intowg0. - AllowedIPs on A: 192.168.20.0/24 is in the peer's
allowed_ips(outgoing encryption to the right peer). - AllowedIPs on B: A's source network is in B's peer
allowed_ips(incoming acceptance). - Firewall on B: a rule passes the traffic arriving on B's
wg0. - Return route on B back to 192.168.10.0/24 via its
wg0.
AllowedIPs are not firewall authorisation: Pebble always requires explicit rules, and validation rejects routes outside a peer's allowed IPs. Site-to-site traffic is never NATed: the NAT rule excludes every internal and peer network.
Validation also rejects 0.0.0.0/0 (no full-tunnel mode), allowed IPs that
overlap the LAN, and routes that would duplicate other routes, so a tunnel
can never replace the WAN default route.
Runtime behaviour#
- Interfaces are created by
rc.d/pebbleat boot (after PF) and byservice pebble wgup wgNduring apply. They stay configured while the remote endpoint is offline; nothing else is removed if a tunnel is down. - Private and preshared keys are loaded from their files with
wg set ... private-key <file>; they never appear in generated configuration, backups (unless requested) or diagnostics. - Endpoints given as host names are set separately so that a DNS failure at
boot cannot stop the interface from coming up. Every five minutes cron
runs
pebble vpn reresolve, which re-resolves only peers whose last handshake is older than three minutes — bounded, not a polling loop.
Diagnostics#
pebble vpn status # interfaces, ports, peers with a recent handshake
pebble vpn peers # endpoint, handshake age, transfer per peer
pebble vpn routes # every peer route resolves to the right interface
pebble vpn test site-b # step-by-step check, see below
pebble vpn show site-b
pebble vpn test checks, in order: the interface exists and is up; the
peer is loaded; a recent handshake; kernel routes point into the tunnel;
traffic counters move while it pings the peer's tunnel address (or
--target); and whether the destination replied. A missing ping reply with
received data is reported as "ICMP may be filtered", not as a failure.
Key rotation#
pebble vpn genkey --rotate wg0 # keeps the old key as wg0.key.old
pebble apply # then give the new public key to every peer
Tunnels using the old key stop working until each peer has the new public key; rotate during a maintenance window.
VPN provider exits#
An interface with role: egress connects to a commercial VPN (ProtonVPN,
Mullvad and so on) but carries nothing by default. Individual devices are
then sent through it from their DHCP reservation, while everything else
keeps using the WAN. See examples/vpn-egress.yaml.
Take the values from the provider's WireGuard configuration file:
| Provider file | pebble.yaml |
|---|---|
[Interface] Address |
address (for example 10.2.0.2/32) |
[Interface] DNS |
gateway and dns (Proton: 10.2.0.1) |
[Interface] PrivateKey |
saved as /usr/local/etc/pebble/keys/wgN.key, mode 0600 |
[Peer] PublicKey, Endpoint |
peers[0].public_key, peers[0].endpoint |
allowed_ips must be [0.0.0.0/0], there is exactly one peer, and
listen_port may be left out. Then assign devices:
dhcp:
reservations:
- {name: laptop, mac: 02:00:00:00:00:50, address: 192.168.10.50, egress: wg1}
What Pebble generates:
- No routes. The tunnel is never in the routing table and is not an internal network, so nothing reaches it by accident.
- Policy routing.
pass in on $lan_if route-to (wg1 <gateway>) from <device> to ! <pebble_internal>, evaluated beforefirewall.rules, so no administrator pass rule can send the device's Internet traffic out of the WAN. Traffic to the LAN, the router and WireGuard sites is not affected and follows the normal rules — which also means block rules infirewall.rulesdo not apply to these devices' Internet traffic. - NAT to the tunnel address (
nat on wg1). - Kill switch. Routed traffic is tagged;
block out quick on $wan_if tagged PEBBLE_VPN_wg1stops it from ever leaving through the WAN. If the tunnel is down, the device has no Internet access. - DNS. With
dnsset, DHCP gives assigned devices those resolvers, and their lookups travel through the tunnel too. Devices pick this up when they renew their lease.
To cut a device off from the Internet altogether, use egress: none
instead (see networking.md).
Check it with pebble firewall explain ("Per-device Internet access") and
pebble firewall test --in lan --src 192.168.10.50 --dst 1.1.1.1 --proto tcp --port 443.