A WireGuard Hub with Ansible

My machines are spread over a number of places that are all behind some kind of NAT: the home network behind an OPNsense firewall, a Mikrotik router on another site, a Raspberry Pi at the chalet, and the laptops that move around with me. A few years ago I solved the “how do I reach home” question with an SSH access through a Tor Hidden Service. It worked, but it was slow, it only gave me SSH, and only allowed me to connect to a server, not link multiple networks together.

Today all these sites dial out to a small public server which acts as a WireGuard hub and routes between them. The same server also runs an frp server so that services in my home Kubernetes cluster can be reached from the Internet without opening anything on the home connection.

Until recently, the configuration of this server was edited by hand. I have now moved it to an Ansible repository, and in this post I will explain how it is organised: the hub-and-spoke model with a single list of peers, the forwarding and iptables rules, the frp server with its systemd unit, and the generation of client configuration files on the controller.

flowchart TB
    internet(["Internet"])

    subgraph hub["Arven - public server"]
        direction LR
        frps["frps<br/>public 80 / 443<br/>control TCP 7000"]
        wg0["wg0 - 10.100.0.1/24<br/>UDP 51820"]
    end

    laptop["Laptop<br/>10.100.0.3"]
    pi["Raspberry Pi<br/>10.100.0.10"]

    subgraph site2["Other site"]
        mk["Mikrotik<br/>10.100.0.9<br/>LAN 192.168.50.0/24"]
    end

    subgraph home["Home"]
        opn["OPNsense<br/>10.100.0.8<br/>VLANs 10.20.0.0/24<br/>and 10.20.16.0/20"]
        subgraph k8s["Kubernetes cluster"]
            direction LR
            frpc["frpc pod"]
            traefik["Traefik<br/>80 / 443"]
            forgejo["Forgejo<br/>SSH 22"]
            frpc --> traefik
            frpc --> forgejo
        end
    end

    internet -- "80 / 443" --> frps

    wg0 <== WireGuard ==> laptop
    wg0 <== WireGuard ==> pi
    wg0 <== WireGuard ==> mk
    wg0 <== WireGuard ==> opn

    frpc -. "dials out to TCP 7000" .-> frps

Repository layout

The repository is small. There is a single host in the inventory and four roles applied by one playbook:

- name: Configure Arven as WireGuard hub and frp server
  hosts: arven
  become: true
  roles:
    - wireguard_hub
    - firewall
    - frps
    - client_configs

The non-secret configuration lives in group_vars/arven/wireguard.yml and group_vars/arven/frps.yml. All the secrets (the hub private key, the private keys of the clients for which I generate a configuration, and the frp token) are in an Ansible Vault file next to them. The other files only reference them with a vault_ prefix, like arven_private_key: "{{ vault_arven_private_key }}", so that the vault can be encrypted without breaking the lookups.

One list of peers

The main idea of the repository is that every tunnel ending on the hub is described in a single wireguard_peers list. Site-to-site tunnels and roadwarriors are the same thing from the point of view of the hub: a public key, a tunnel address, and optionally some networks behind it. A shortened version looks like this:

wg_interface: wg0
wg_address: 10.100.0.1/24
wg_listen_port: 51820
wg_network: 10.100.0.0/24
wg_dns: 10.100.0.1
public_endpoint: "vpn.example.org:51820"

wireguard_peers:
  - name: Laptop
    address: 10.100.0.3/32
    public_key: "<laptop-public-key>"
    keepalive: 15
    manage_config: true
    private_key: "{{ vault_laptop_private_key }}"

  - name: OPNSense
    address: 10.100.0.8/32
    public_key: "<opnsense-public-key>"
    keepalive: 15
    site_subnets:
      - 10.20.0.0/24
      - 10.20.16.0/20

  - name: Mikrotik
    address: 10.100.0.9/32
    public_key: "<mikrotik-public-key>"
    keepalive: 15
    site_subnets:
      - 192.168.50.0/24

  - name: Raspberry-Chalet
    address: 10.100.0.10/32
    public_key: "<chalet-public-key>"
    keepalive: 15

The real list has a few more entries but the structure is the same. The site_subnets key is what makes a peer a site: it lists the LANs that are reachable through it. manage_config tells Ansible that I hold the private key of that peer and that it should render a client file for it. The keepalive is set on everything because all the peers are behind NAT and the hub can never initiate the connection.

From this list, two values are derived in the same file:

wg_routed_subnets: "{{ wireguard_peers
  | selectattr('site_subnets', 'defined')
  | map(attribute='site_subnets') | flatten | unique }}"

managed_peers: "{{ wireguard_peers
  | selectattr('manage_config', 'defined')
  | selectattr('manage_config') | list }}"

The first one is the union of all the networks behind all the sites, the second one the peers for which a client configuration is generated.

The hub interface

The wireguard_hub role installs wireguard-tools, enables IPv4 forwarding in /etc/sysctl.d/99-wireguard-router.conf and renders /etc/wireguard/wg0.conf. The template loops over the peers and gives each one its tunnel address plus its site subnets as AllowedIPs:

{% for peer in wireguard_peers %}
{% if peer.public_key and not peer.public_key.startswith('REPLACE_') %}
# {{ peer.name }}
[Peer]
PublicKey = {{ peer.public_key }}
AllowedIPs = {{ ([peer.address] + (peer.site_subnets | default([])))
                | join(', ') }}
PersistentKeepalive = {{ peer.keepalive | default(25) }}

{% endif %}
{% endfor %}

With WireGuard, AllowedIPs on the hub is both the access list and the routing table: a packet for 192.168.50.10 arriving on wg0 is sent to the Mikrotik peer because that peer owns 192.168.50.0/24. There is no need for extra routes on the hub, wg-quick adds them.

The part I care most about is that a mistake in this file must not take down all the tunnels at once, since I am usually connected through one of them when I run the playbook. So there are two layers of checks. First, assertions on the controller check that the hub private key and every peer public key look like WireGuard keys (43 base64 characters followed by =). These also run with --check. Then the template task uses the validate option to load the rendered candidate into a throwaway interface that is never brought up:

wireguard_config_validate_cmd: >-
  bash -c 'ip link del dev wgvalidate 2>/dev/null || true;
  ip link add dev wgvalidate type wireguard &&
  wg setconf wgvalidate <(wg-quick strip %s);
  rc=$?;
  ip link del dev wgvalidate 2>/dev/null || true;
  exit $rc'

This is the same parsing wg-quick up does, so a malformed key or a duplicate AllowedIPs fails here. Because Ansible runs the command on the temporary file, the live wg0.conf stays untouched and the restart handler never fires.

Forwarding and iptables

Enabling net.ipv4.ip_forward is not enough, the hub also needs to accept forwarded traffic. The firewall role installs iptables-persistent and deploys /etc/iptables/rules.v4:

*filter
:INPUT ACCEPT [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]

-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Open WireGuard hub forwarding (roadwarrior <-> site tunnels).
-A FORWARD -i wg0 -o wg0 -j ACCEPT
-A FORWARD -i wg0 -j ACCEPT
-A FORWARD -o wg0 -j ACCEPT

COMMIT

The default policy for forwarding is to drop, and only traffic entering or leaving through wg0 is allowed. This is deliberately open between the peers: every peer can reach every advertised subnet. That is fine for me because all the peers are my own devices, but it is a trade-off. Filtering between sites is left to the OPNsense and the Mikrotik, which see the traffic arriving from the tunnel.

Client configurations on the controller

For the peers with manage_config: true, the client_configs role renders an importable .conf file. It runs with delegate_to: localhost and become: false, so the files are written on my machine into generated/clients/, a directory with mode 0700 that is ignored by git. The template is short:

[Interface]
PrivateKey = {{ peer.private_key }}
Address = {{ peer.address }}
DNS = {{ wg_dns }}

[Peer]
PublicKey = {{ arven_public_key }}
Endpoint = {{ public_endpoint }}
AllowedIPs = {{ ([wg_network] + wg_routed_subnets)
                | reject('in', peer.site_subnets | default([]))
                | list | join(', ') }}
PersistentKeepalive = {{ peer.keepalive | default(25) }}

The interesting line is AllowedIPs: each client gets the tunnel network plus every advertised site subnet, minus its own. A laptop therefore reaches the home VLANs and the Mikrotik LAN through the hub, and the Mikrotik, which is itself a managed peer, does not try to route its own LAN into the tunnel. For a laptop, the rendered file looks like this:

[Interface]
PrivateKey = <laptop-private-key>
Address = 10.100.0.3/32
DNS = 10.100.0.1

[Peer]
PublicKey = <hub-public-key>
Endpoint = vpn.example.org:51820
AllowedIPs = 10.100.0.0/24, 10.20.0.0/24, 10.20.16.0/20, 192.168.50.0/24
PersistentKeepalive = 15

It can be imported directly with nmcli, the WireGuard app on a phone, or copied to /etc/wireguard/. The client private keys stay in the vault and on the controller; the hub only ever sees public keys. Adding a new device is now a matter of generating a key pair, putting the public key in wireguard.yml, the private key in the vault, and running the playbook:

$ wg genkey | tee /tmp/priv | wg pubkey
$ ansible-playbook playbooks/site.yml --check --diff
$ ansible-playbook playbooks/site.yml

For peers like the chalet Raspberry Pi or the OPNsense, I do not manage the configuration from here. Their private key was generated on the device and never left it, so they only appear in the list with their public key.

Public ingress with frps

The second job of Arven is to be the public entry point for the services running at home. Previously this was done with HAProxy relaying TCP connections. It is now done with frp: the frps server runs on Arven, and an frpc client inside the cluster dials out to it and registers the ports it wants to expose.

The frps role downloads a pinned release from GitHub (with an optional checksum), installs the binary only if the version changed, creates a frps system user, and renders /etc/frp/frps.toml. The variables are:

frps_version: "0.69.1"
frps_bind_port: 7000
frps_vhost_http_port: false
frps_vhost_https_port: false
frps_auth_token: "{{ vault_frps_auth_token }}"
frps_dashboard_enabled: false
frps_allow_ports:
  - "single = 80"
  - "single = 443"
  - "single = 10022"

The HTTP and HTTPS virtual host features of frp are disabled because I reproduce what HAProxy did: raw TCP passthrough on ports 80 and 443, with TLS terminated in the cluster. The allowPorts list restricts the ports a client may ask for to exactly the three in use, so a leaked token would not allow somebody to open arbitrary ports on the server. Before starting a public frps, an assertion checks that the token is set and is not a placeholder, and the template task validates the new configuration with frps verify -c before it replaces the live one, the same idea as for WireGuard.

The systemd unit runs frps as the unprivileged user and only grants it the capability to bind low ports:

[Service]
Type=simple
User=frps
Restart=on-failure
RestartSec=5s
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
LimitNOFILE=1048576

The frpc side in the cluster

On the home lab cluster, frpc is a normal Deployment in its own namespace, deployed by ArgoCD from the kubernetes-lab repository. Its configuration is a ConfigMap generated from frpc.toml:

serverAddr = "vpn.example.org"
serverPort = 7000

auth.method = "token"
auth.token = "{{ .Envs.FRP_AUTH_TOKEN }}"

[[proxies]]
name = "ingress-https"
type = "tcp"
localIP = "traefik-ingress.traefik-ingress.svc.cluster.local"
localPort = 443
remotePort = 443

[[proxies]]
name = "git-ssh"
type = "tcp"
localIP = "forgejo.forgejo.svc.cluster.local"
localPort = 22
remotePort = 10022

There is a third proxy for port 80 to the same Traefik Service. Ports 80 and 443 on Arven land on the Traefik ingress, which does the TLS and the routing to the applications, and port 10022 goes to the SSH port of Forgejo for git over SSH.

The token comes from an encrypted Secret, injected as an environment variable. Here I lost some time: frp expands these templates with Go’s html/template, which HTML-escapes the value. A base64 token with a + became &#43; on the client and did not match the server. The fix is to use a hex token, generated with openssl rand -hex 32.

The pod runs as non-root with a read-only root filesystem and all capabilities dropped. A NetworkPolicy denies all ingress, since frpc only makes outbound connections, and limits egress to the cluster DNS, the Traefik and Forgejo namespaces, and port 7000 on the address of Arven. The Deployment uses the Recreate strategy, because frps rejects a second client registering the same proxy names, so a rolling update would leave the new pod failing until the old one is gone.

What is left

The setup has been simplified a lot by having one list to edit, but there are a few things I would like to improve. The forwarding between peers is fully open and could be restricted per peer. The return routes on the OPNsense and the Mikrotik are still configured by hand on those devices. The frps version used in the cluster image (0.61.1) and the server version are not kept in sync automatically. And Arven is a single point of failure: when it is down, the sites cannot talk to each other and the public services are unreachable.

The migration itself did not go perfectly: during one of the first playbook runs, an error in the configuration dropped the WireGuard connections of all the peers. This is where I was happy that the public traffic does not go through the tunnel. The frpc client connects to Arven over the Internet, not over wg0, so the websites and the git server stayed reachable while I was fixing the WireGuard side.

Still, compared to the Tor hidden services, I now have real routing between all the sites, the public services do not depend on the home connection accepting incoming traffic, and adding a new device is one entry in a YAML file and a playbook run.