flu0r1ne.net/logs/wg-quick-deep-diveLast Updated: 2023-09-08

wg-quick(8) on Linux - a deep dive

Perhaps you have decided to secure your company's internal database or tunnel all your traffic through a VPN. You enter the command wg-quick up wg0, watch as commands scroll past your screen, and suddenly realize you can't access the network.

If this situation sounds familiar, you're not alone. WireGuard is a Layer 3 VPN that has become the de-facto standard for good reasons — it's fast, simple, and secure. Anecdotally, it's known to be easier to configure than its bulkier and more convoluted predecessor, OpenVPN. However, configuring or debugging WireGuard networks requires a robust understanding of networking, something the thorough WireGuard documentation can help most users with.

I recently reviewed the source for wg-quick(8) and discovered that it was not entirely documented. Some of the documented parts assumed an in-depth understanding of networking. This research turned out to be a surprisingly instructive exercise in Linux networking, and I'm sharing my results here. Hopefully, it can fill in some gaps in the existing documentation.

In particular, this guide:

  1. Provides additional exposition on Linux networking
  2. Details default-route handling
  3. Explains the default firewall configuration
  4. Describes the multicast limitations of WireGuard tunnels

The Basics

WireGuard adheres to the Unix philosophy of "doing one thing well," fostering a design of modular systems. Accordingly, it integrates into the Linux networking stack as a network interface, configured through the user space tool wg(8). However, to enable traffic flow, IP addresses must be assigned, and routes need to be configured. While this is a relatively simple procedure, it can become tedious and requires automation to ensure reliability. This is where wg-quick(8) steps in.

This is an extremely simple script for easily bringing up a WireGuard interface, suitable for a few common use cases. ... Generally speaking, this utility is just a simple script that wraps invocations to wg(8) and ip(8) in order to set up a WireGuard interface. It is designed for users with simple needs, and users with more advanced needs are highly encouraged to use a more specific tool, a more complete network manager, or otherwise just use wg(8) and ip(8), as usual.

- wg-quick(8)

wg-quick serves as a straightforward orchestration tool for creating and removing WireGuard tunnels. It is the de-facto standard for configuring tunnels since it is cross-platform, supported by all official clients. Its strength lies in the fact that the configuration provides a complete description of the tunnel, allowing tunnels to be brought up or down with a single command. Additionally, the interface can be initiated at boot through systemd, although - oddly - this feature remains undocumented. Without additional tooling, wg-quick can configure either a static server endpoint or a roaming peer.

A "static server endpoint" refers to a computer with a static IP, typically provided by cloud computing vendors, which functions as a node connecting peers. Often, it may facilitate access to protected resources, like a database. A roaming peer, on the other hand, is a machine that connects using the static IP of the server and can access the server or other peers. Peers typically lack a fixed endpoint and "roam" from one IP to another.

There's also a special provision for cases where a peer routes all their internet traffic through the tunnel, aligning with how most consumers conceive of a VPN. However, VPNs can also selectively transmit traffic bound for specific computers. Unfortunately, the server configuration for this setup cannot be handled with wg-quick alone since it necessitates Network Address Translation (NAT). This limitation likely stands as an exception, enabling VPN providers to distribute wg-quick configurations instead of writing their own tools to route traffic.

wg-quick is by no means the sole configuration tool for WireGuard. It's supported alongside other network management systems such as networkd and NetworkManager. Specifically, NetworkManager is a prevalent tool for managing WiFi networks on desktop environments, and it has the capability to import WireGuard tunnels using the .conf format, just like wg-quick. (The extent of feature compatibility between them, however, is something I'm not fully aware of at the moment.) On the other hand, networkd is more commonly leveraged for network management on servers. In the near future, I'll be releasing a tool that can "import" (or more accurately, transpile) wg-quick files into networkd configurations. When choosing between these options, it would be wise to defer to your system's network manager. Most users configure WireGuard tunnels within the initial network namespace. Multiple tools can end up tripping over one another while managing the same network resources.

It is also worth stating that wg-quick is not the only configuration tool. WireGuard is supported both by networkd and NetworkManager. NetworkManager is commonly used to manage WiFi networks on the desktop and can import WireGuard tunnels using the .conf format shared with wg-quick. (Although I am unaware of the feature compatibility.) networkd is commonly used to manage networks on the server. Soon, I will be releasing a tool I wrote which allows wg-quick files to be "imported" (or more accurately transpiled) to networkd. When considering which option to use, I would default to your system's network manager. WireGuard tunnels affect resources in the global network namespace, so it makes sense for them to be managed by an entity responsible for managing network resources globally.

The process of manually configuring a WireGuard tunnel is thoroughly outlined on the quickstart page. If you've set up a network with static IP addresses, you might already be acquainted with this process. However, many enthusiasts and developers may have avoided this route, since the aptly named Dynamic Host Configuration Protocol (DHCP) takes care of automating IP management. In the following section, I'll delve into configuring network interfaces on Linux, assuming that you possess a fundamental understanding of networking concepts. Should you be new to setting up a static network, or if you find yourself unfamiliar with terms like private IP address space, subnets, public IPs, routers, subnet masks, and interfaces, I strongly recommend taking the time to acquaint yourself with these concepts before proceeding.

Revisiting a Simple Network

WireGuard seamlessly integrates with the Linux networking stack, functioning as a networking interface. The Linux networking system is indeed robust, supporting a wide array of features. However newcomers should be warned, the documentation often appears to lag behind the pace of feature development. In this article, we'll explore the fundamentals of a simple Local Area Network (LAN) and shed light on how packets are routed on contemporary Linux systems. This foundational knowledge is essential for understanding how wg-quick interfaces with these routing constructs.

Assigning a static IP address to a networking interface can be accomplished using the following command. This sets up a LAN with the subnet 192.168.0.0-127 and assigns the networking interface the IP address 192.168.0.22:

[#] ip -4 addr add 192.168.0.22/25 dev eth0

Should you attempt to transmit data to a peer at 192.168.0.11 (e.g., using a UDP socket), how does the kernel determine where to route these packets? Upon executing the command above, the kernel modifies a structure known as the routing table. Routing tables function as a database, directing traffic to a specific interface by matching the destination IP address. Most routes are automatically appended to the main routing table, which can be viewed with the command ip route show:

[#] ip -4 route show table main

192.168.0.0/25 dev eth0 proto kernel scope link src 192.168.0.22

In essence, packets destined for our subnet will be sent using eth0 with the source IP address 192.168.0.22. The kernel automatically adds this route for a subnet assigned to a link, known as a prefix route. If multiple competing routes exist in a routing table, the route is selected using the longest prefix match algorithm. Essentially, the most specific route, characterized by the longest subnet mask, is chosen. If you add a custom route specifying that 192.168.0.11/32 should be sent using eth1, traffic will flow through this interface instead, since the 32-bit subnet mask is longer than the 25-bit subnet mask:

[#] ip -4 route add 192.168.0.11/32 dev eth1
[#] ip -4 route get 192.168.0.11
192.168.0.11 dev eth1 src 192.168.0.12 uid 0
    cache

This brings up an intriguing question: If we send traffic to the current host at 192.168.0.22, how does the routing algorithm determine that the packets shouldn't leave the computer but instead be handled by local programs? The answer lies in another table that precedes the main table, known as the local table. The local table is consulted before the main table, and if a match is found, the packet is routed accordingly. The kernel also manipulated this table in response to our earlier ip addr add command.

[#] ip -4 route show table local

local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
broadcast 127.255.255.255 dev lo proto kernel scope link src 127.0.0.1
local 192.168.0.22 dev eth0 proto kernel scope host src 192.168.0.22
broadcast 192.168.0.127 dev eth0 proto kernel scope link src 192.168.0.22

The original ip addr add command added both a local route and a broadcast route. Specifically, the entry local 192.168.0.22 dev eth0 stipulates that packets addressed to 192.168.0.22 should be "looped back" and delivered to sockets listening locally on eth0, or bound to the address 192.168.0.22. Typically, IP traffic is unicast, meaning it originates with one host and is directed at another. The broadcast rule informs the kernel that 192.168.0.127 is a broadcast address. By default, the kernel utilizes the highest IP address in the subnet as the broadcast address.

The above overview offers almost a complete picture, but there's another crucial layer of indirection in play: the Routing Policy Database (RPDB). The RPDB acts as a guide, specifying how routing tables are selected and ordered. This can be queried with the ip rule command, and the initial state of the RPDB might look something like this:

[#] ip rule

0:	from all lookup local
32766:	from all lookup main
32767:	from all lookup default

Quoting directly from the manual provides a comprehensive explanation:

Each policy routing rule consists of a selector and an action predicate. The RPDB is scanned in order of decreasing priority (note that a lower number means higher priority, see the description of PREFERENCE below). The selector of each rule is applied to {source address, destination address, incoming interface, tos, fwmark} and, if the selector matches the packet, the action is performed. The action predicate may return with success. In this case, it will either give a route or failure indication and the RPDB lookup is terminated. Otherwise, the RPDB program continues with the next rule.

Semantically, the natural action is to select the nexthop and the output device.

At startup time the kernel configures the default RPDB consisting of three rules:

  1. Priority: 0, Selector: match anything, Action: lookup routing table local (ID 255). The local table is a special routing table containing high priority control routes for local and broadcast addresses.
  2. Priority: 32766, Selector: match anything, Action: lookup routing table main (ID 254). The main table is the normal routing table containing all non- policy routes. This rule may be deleted and/or overridden with other ones by the administrator.
  3. Priority: 32767, Selector: match anything, Action: lookup routing table default (ID 253). The default table is empty. It is reserved for some post-pro‐ cessing if no previous default rules selected the packet. This rule may also be deleted.

- ip-rule(8)

If you've been following along, you now understand that when a socket binds or connects to an address, the kernel selects an appropriate route by querying the routing tables. This query is guided by the rules specified in the routing policy database (RPDB). When a table contains multiple routes, the longest prefix match algorithm is used to determine the most specific route, and that is the one selected.

Note: Policy routing can be bypassed using the SO_BINDTODEVICE option. This allows you to bind a socket directly to a specific device, effectively sidestepping the standard routing process.

Bringing Up a Site-to-Site-Style Tunnel with wg-quick(8)

Use up to add and set up an interface, and use down to tear down and remove an interface. Running up adds a WireGuard interface, brings up the interface with the supplied IP addresses, sets up mtu and routes, and optionally runs pre/post up scripts.

- wg-quick(8)

The command wg-quick first searches for a configuration file. If the first argument to wg-quick up matches the pattern [a-zA-Z0-9_=+.-]{1,15}, which represents a valid interface name on Linux, the file is assumed to exist in /etc/wireguard with the provided name and a .conf extension. Once a suitable file is found, its contents are read. For example, it might read the following file, wg0.conf:

[Interface]
PrivateKey = yDdqzxdE66e64xy5Qu1PshT0ybQJLHbU9N+91PS1Dng=
Address = 192.168.42.1/24

[Peer]
PublicKey = o837llPmQ4t9cN0rmiLasp6SF54dAzS0Ea1p71c1jFA=
AllowedIPs = 192.168.42.0/32
Endpoint = 19.216.242.139:16262

[Peer]
PublicKey = YIUKiCiw9+6an3HnDn7t3CwlF30ERQkhEQ6f3jRBUnk=
AllowedIPs = 10.1.0.0/16
Endpoint = 19.216.242.138:16263

The process begins identically to the steps taken in the quick start documentation. A WireGuard interface is added to the system with the interface name from the corresponding file:

ip link add dev wg0 type wireguard

Then, the WireGuard configuration is obtained by stripping out the wg-quick-specific sections, passing the rest to the wg command. This configuration can be produced using the wg-quick strip command. Running wg-quick strip on the above example removes the Address section, and wg parses the configuration to pass it on to the kernelspace driver [1].

[1] It can also communicate this information to the userspace implementation, if available.

[#] wg-quick strip wg0.conf
[Interface]
PrivateKey = yDdqzxdE66e64xy5Qu1PshT0ybQJLHbU9N+91PS1Dng=

[Peer]
PublicKey = o837llPmQ4t9cN0rmiLasp6SF54dAzS0Ea1p71c1jFA=
AllowedIPs = 192.168.42.0/32
Endpoint = 19.216.242.139:16262

[Peer]
PublicKey = YIUKiCiw9+6an3HnDn7t3CwlF30ERQkhEQ6f3jRBUnk=
AllowedIPs = 10.1.0.0/16
Endpoint = 19.216.242.138:16263

As the official documentation states:

The configuration file adds a few extra configuration values to the format understood by wg(8) in order to configure additional attributes of an interface. It handles the values that it understands, and then it passes the remaining ones directly to wg(8) for further processing.

- wg-quick(8)

The way the interface configuration affects packet routing is well-covered by the original documentation. If you haven't read it, you should explore the "Simple Network Interface" and "Cryptokey Routing" sections before continuing.

[#] wg-quick strip wg0.conf > wg0-stripped.conf
[#] ip setconf wg0 wg0-stripped.conf

Next, the IP addresses specified in Addresses are added to the interface. For the above configuration, it assigns the IP address 192.168.42.1 to the WireGuard interface. This becomes the source IP address for packets traveling to the peer VPN endpoint [3]. The system also automatically creates a prefix route directing traffic from any IP address matching 192.168.42.0/24 to the interface wg0, and a local route for 192.168.42.1.

[3] The source IP address, as defined in the Addresses section of the configuration, serves as the originating address for packets sent to the peer VPN endpoint. However, this can be overridden using a "raw socket," in which an application has the ability to define all fields of a packet, including the source IP. While this introduces significant security considerations, WireGuard's Cryptorouting scheme substantially mitigates the risks. Malicious peers are restricted to spoofing only those addresses listed in the AllowedIPs section of the [Peer] configuration, and all unauthorized packets are promptly dropped. This built-in mechanism aids in containing potential threats.

[#] ip -4 addr add 192.168.42.1/24 dev wg0

Afterward, the link is brought up, and the MTU (Maximum Transmission Unit) is set. The MTU represents the maximum size of a packet that can be communicated on a link. If unspecified, the MTU of the underlying link is obtained, and the MTU of the WireGuard tunnel is reduced by 80 bytes to exclude the overhead of the WireGuard encapsulation packets. I looked into this and am still unsure as to why an 80 byte MTU reduction was chosen. My working theory is that this value was chosen to cover both IPv4 and IPv6 with a factor of safety [4].

[4] The encapsulation method depends on whether the tunnel endpoint is an IPv4 address or an IPv6 address. The maximum size of an IPv4 packet is 60 bytes. IPv6 packets are more challenging to quantify since they can have any number of arbitrary header extensions. The base IPv6 header is only 40 bytes but additional headers can add up. Accordingly, 80 bytes would cover an IPv6 header with a few extensions.

[#] ip link set mtu up 1420 dev wg0

Finally, routes are added for all the entries of AllowedIPs to the main routing table:

[#] ip -4 route add 192.168.42.0/32 dev wg0
[#] ip -4 route add 10.1.0.0/16 dev wg0

Default-Route Handling

The above example illustrates how WireGuard establishes static routes for specific segments of the IP space, which we specified in AllowedIPs. However, these previous examples assumed that the peer endpoint falls outside any of the AllowedIP ranges. If this were not true, the routes would create a routing loop, with packets exiting the WireGuard interface being routed back into it.

One common use case for a VPN is to route all traffic to an endpoint which functions as a NAT router. (If you are unfamiliar with NAT, picture your home router home router.) This can enhance privacy, reduce the specificity of a user's public IP address in fingerprinting systems, prevent ISPs from selling data to advertisers, or obscure the regional ISP an individual is using. To route all traffic, we need a default route 0.0.0.0/0 and ::0/0 which match all destination IPs. This creates an issue since our endpoint's IP will fall into this range. Fortunately, there are methods to make default routes work as intended.

Linux policy routing can be used in various ways to solve this problem, and the Wireguard Routing and Namespace Documentation outlines a few potential solutions. wg-quick elegantly accomplishes this with the following rules, which require some explanation:

[#] wg set wg0 fwmark 51820
[#] ip -4 route add 0.0.0.0/0 dev wg0 table 51820
[#] ip -4 rule add not fwmark 51820 table 51820
[#] ip -4 rule add table main suppress_prefixlength 0

The Linux kernel routing system features fwmark, an integer marker ranging from 0 to 2^32 - 1 which can be attached to a packet. It designates that the packet should be routed or filtered according to special rules. Packets with this mark can either be routed to a specific table using policy routing (ip-rule) or filtered with the nftables framework. The fwmark can be set either within the netfilter subsystem or by a program when making a network connection [4].

[4] setsockopt can be used with SO_MARK to set the outgoing mark as so long as the process has the CAP_NET_ADMIN capability.

In this instance, wg-quick marks all packets leaving the tunnel with the fwmark 51820:

[#] wg set wg0 fwmark 51820

Recall our previous discussion of routing tables. wg-quick creates a new routing table within which the default route directs all traffic to the wg0 interface, effectively serving as a "default gateway." With none of the mask bits set, all traffic is routed to wg0:

[#] ip -4 route add 0.0.0.0/0 dev wg0 table 51820

Traffic must then be directed to this routing table. Here, all traffic that has not exited the VPN interface (and thus does not have this fwmark) is sent to the table with the default route:

[#] ip -4 rule add not fwmark 51820 table 51820

By default, the IP command assigns this rule a higher priority than the rule querying the main table.

[#] ip rule
0:	from all lookup local
32765:	not from all fwmark 0xca6c lookup 51820
32766:	from all lookup main
32767:	from all lookup default

We have established that traffic will be directed through the Wireguard interface if it does not match any local IP ranges. It will then be sent through wg0 before being routed according to the main table (and likely exit through a default gateway.)

The ip rule command prints the mark in hex as 0xca6c.

Handling Local Network Traffic

While VPN traffic will flow as expected, this configuration may have unintended consequences. All local LAN traffic will be directed to the tunnel, preventing access to the local network (except for encrypted Wireguard packets directed to the default gateway). Policy routing has one more trick:

[#] ip -4 rule add table main suppress_prefixlength 0

The command suppress_prefixlength rejects all routes with prefixes equal to or less than the specified length. So, suppress_prefixlength 0 rejects all default gateways, causing the routing algorithm to query the main table first, which typically contains LAN routes but can also contain routes setup by virtualisation software.

These rules are set whenever the default IPv4 0.0.0.0/0 or IPv6 ::/0 routes appear in the AllowedIPs. In the case of IPv6, ip -6 rules are added as well. Finally, if FwMark is not specified, wg-quick searches for the next available mark, stopping when an empty routing table is found. Finally, the FwMark is set to the routing table number.

The Default Firewall

wg-quick configures a firewall if nftables is installed and a default route is specified. The following firewall rules are setup when an IPv4 endpoint is specified:

table ip wg-quick-wg0 {
    chain preraw {
        type filter hook prerouting priority raw; policy accept;
        iifname != "wg0" ip daddr 192.168.42.1 fib saddr type != local drop
    }

    chain premangle {
        type filter hook prerouting priority mangle; policy accept;
        meta l4proto udp meta mark set ct mark
    }

    chain postmangle {
        type filter hook postrouting priority mangle; policy accept;
        meta l4proto udp meta mark 0x0000ca6c ct mark set meta mark
    }
}

The first rule in the preraw chain mitigates a potential vulnerability that could allow a network attacker to access servers listening on wg0. It's easy to assume that local services listening on the WireGuard IP address are safe since they're only accessible to those connected to the tunnel. However, this is not the case.

Imagine an attacker connected on eth0, an adjacent ethernet interface, attempting to access the address assigned to our WireGuard interface, 192.168.42.1. Since a route for this address is in the local table, the attacker can send packets to 192.168.42.1 and receive responses, even if the requests originate from another subnet. This could enable the attacker to exfiltrate sensitive data or inject malicious packets. Therefore, the first rule enforces that services on wg0 can only be accessed through addresses on the same interface.

Reverse Path Forwarding

The last two rules in the code snippet above pertain to reverse path forwarding, a technique used to prevent spoofed packets from entering a network. Consider a router with forwarding enabled and two links:

172.30.1.0/31 dev eth-01 proto kernel scope link src 172.30.1.0
172.30.2.0/31 dev eth-02 proto kernel scope link src 172.30.2.0

Now, think about a scenario where a router connected to eth-02 with IP 172.30.2.1 and an attacker connected to this router attempt to perform a denial-of-service attack against 1.1.1.1 by spoofing network ICMP packets with random destination IPs and the source address of their victim. Classically, if one of these spoofed packets "from" 1.1.1.1 destined for 172.30.2.1 arrives on eth-01, it would be forwarded. However, this would be suspicious since a packet from 172.30.2.1 destined for 1.1.1.1 would be dropped, not forwarded through eth-02. By considering if a routable path exists in the reverse direction, a router can automatically drop spoofed packets. This concept is known as reverse path forwarding, as defined by RFC 3704, known as "strict reverse path forwarding."

To enable strict reverse path filtering in the Linux kernel, you can use the following sysctl switch:

[#] sysctl net.ipv4.conf.INTERFACE_NAME.rp_filter=1

Most Linux distributions default to Loose Reverse Path Forwarding. In strict mode, traffic from 172.30.1.1 on eth-02 would be dropped, as it should have routed through eth-01. This enforces a symmetrical routing policy but can disrupt asymmetric routing configurations. Loose Reverse Path Forwarding, conversely, processes traffic if routable through any interface.

Why does this matter for WireGuard? A firewall's secondary set of rules ensures that strict reverse path forwarding operates accurately. The fwmark is set by the traffic traversing the reverse path, working in collaboration with Linux's conntrack subsystem, which persistently associates incoming traffic with its corresponding outgoing traffic [5]. The second rule sets the "connection mark" labeled ct mark as the fwmark during connection establishment, while the third rule copies it back when packets are received.

wg-quick then instructs the kernel to use the fwmark for reverse path forwarding, since this is not enabled by default:

[#] sysctl -q net.ipv4.conf.all.src_valid_mark=1

[5] For example, in tracking a UDP stream, both IPs (source and destination) and L4 headers (source and destination ports) can be tracked to identify a connection.

Additional Thoughts

When researching WireGuard and reading through the documentation, I found there were a few non-obvious technical details that seemed undocumented, although potentially important.

UDP Socket Parameters

WireGuard permits users to modify the UDP port on which an interface listens using ListenPort. Although, it always binds its internal UDP socket to all available interfaces using INADDR_ANY [6]. This is an important consideration reasoning about packet flows and writing netfilter rules.

Broadcast and Multicast Traffic

Broadcast and multicast traffic are used in standard protocols, notably service autodiscovery. When considering how to configure effective firewalls, I was led to ask whether WireGuard carried multicast traffic. Short answer: yes and no. If the multicast ip range 224.0.0.0/4 or ff00::/8 appear in the AllowedIPs for the tunnel, multicast traffic could theoretically be broadcast from the other peer. Although, this works with at most two peers. Internally, WireGuard uses a single prefix tree to select the endpoint to which a packet is sent. If the same AllowedIPs appear within the configuration of multiple peers, the last peer configuration overwrites the routes configured on other peers [7]. Thus Mutlicast traffic can be directed to, at most, one peer. Thus, if you want to pass broadcast or multicast traffic, a secondary encapsulation method must be used on top of WireGuard. This does not mean peers with default routes in their AllowedIPs may leak Multicast traffic. This seems to be technically possible.

In practice, the interface flags are used by programs like avahi to determine on which interfaces they should broadcast [8]. WireGuard is not started with the IFF_MULTICAST or IFF_BROADCAST flags [8]. According to comments in the Linux kernel, links without IFF_MULTICAST can perform multicast but point-to-point devices cannot broadcast [9]. The exact meaning of these flags seem ambiguous from the limited documentation, and I wouldn't be surprised if the kernel and userspace developers were on different pages as to their exact technical meaning.

IFF_MULTICAST means that this media uses special encapsulation for multicast frames. Apparently, all IFF_POINTOPOINT and IFF_BROADCAST devices are able to use multicasts too.

- [9]

[6] Kernel cGit Github

[7] Github

[8] Kernel cGit Github

[9] Kernel cGit Github