pebble.
Pebble / Docs / Networking

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:

  1. Firewall on A: a rule passes LAN → site B (allow_lan_to_wan does not cover internal or site destinations).
  2. Kernel route on A: routes sends 192.168.20.0/24 into wg0.
  3. AllowedIPs on A: 192.168.20.0/24 is in the peer's allowed_ips (outgoing encryption to the right peer).
  4. AllowedIPs on B: A's source network is in B's peer allowed_ips (incoming acceptance).
  5. Firewall on B: a rule passes the traffic arriving on B's wg0.
  6. 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#

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:

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.