Wireguard can be configured manually using systemd-networkd. This setup connects the Wireguard tunnel and sets up the necessary IP routes and firewall rules to send and accept all traffic over the Wireguard interface.

NixOS is my preferred system, so I’ve specified the configuration using NixOS here. Translation to a non-NixOS system should be straightforward.

{ ... }:
let
  # This peer's IPv4 and IPv6 addresses
  wgIpv4 = "1.2.3.4";
  wgIpv6 = "1:2:3:4::";

  # Variables controlling connection marks and routing tables IDs. You probably
  # don't need to touch this.
  wgFwMark = 4242;
  wgTable = 4000;
in
{
  # DNS is required if the Wireguard endpoint is a hostname.
  networking.nameservers = [
    "1.1.1.1"
    "9.9.9.9"
  ];
  services.resolved.enable = true;

  networking.useNetworkd = true;
  systemd.network = {
    enable = true;
    netdevs."15-wg0" = {
      netdevConfig = {
        Kind = "wireguard";
        Name = "wg0";
        MTUBytes = "1420";
      };
      wireguardConfig = {
        PrivateKeyFile = "/path/to/this-peer's-wg-key";
        FirewallMark = wgFwMark;
        RouteTable = "off";
      };
      wireguardPeers = [
        {
          wireguardPeerConfig = {
            PublicKey = "<The remote peer's public key here>";
            # Depending on the connection, add a pre-shared key file
            # PresharedKeyFile = "/path/to/preshared-key";
            Endpoint = "wg.example.com:51820";
            AllowedIPs = [ "0.0.0.0/0" "::/0" ];
            PersistentKeepalive = 25;
            RouteTable = "off";
          };
        }
      ];
    };
    networks."15-wg0" = {
      matchConfig.Name = "wg0";
      # Set to this peer's assigned Wireguard address
      address = [
        "${wgIpv4}/32"
        "${wgIpv6}/128"
      ];
      networkConfig = {
        # If DNS requests should go to a specific nameserver when the tunnel is
        # established, uncomment this line and set it to the address of that
        # nameserver. But see the note at the bottom of this page.
        # DNS = "1.1.1.1";
      };
      routingPolicyRules = [
        {
          routingPolicyRuleConfig = {
            Family = "both";
            Table = "main";
            SuppressPrefixLength = 0;
            Priority = 10;
          };
        }
        {
          routingPolicyRuleConfig = {
            Family = "both";
            InvertRule = true;
            FirewallMark = wgFwMark;
            Table = wgTable;
            Priority = 11;
          };
        }
      ];
      routes = [
        {
          routeConfig = {
            Destination = "0.0.0.0/0";
            Table = wgTable;
            Scope = "link";
          };
        }
        {
          routeConfig = {
            Destination = "::/0";
            Table = wgTable;
            Scope = "link";
          };
        }
      ];
      linkConfig.RequiredForOnline = false;
    };
  };

  networking.nftables = {
    enable = true;
    ruleset = ''
      table inet wg-wg0 {
        chain preraw {
          type filter hook prerouting priority raw; policy accept;
          iifname != "wg0" ip daddr ${wgIpv4} fib saddr type != local drop
          iifname != "wg0" ip6 daddr ${wgIpv6} 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 ${toString wgFwMark} ct mark set meta mark
        }
      }
    '';
  };
}

This configuration is similar to one that the wg-quick utility sets up. There are a number of important things going on.

  1. FirewallMark on the Wireguard config inside the netdev block sets a mark on packet metainformation outgoing from the Wireguard tunnel. This mark is used in the routing policy rules: the configuration creates a routing table, with an associated high-priority routing policy rule, to send all packets without this mark to the Wireguard interface.

    Packets outgoing from the Wireguard interface must be routed to the main interface (enp3s0 in this case) to make it to the Wireguard peer: if it were routed to the Wireguard interface, it would loop forever. Instead, an outgoing Wireguard packet traverses the routing chain again, and, this time with the mark set in the metainformation, does not match the routing rule, skips the Wireguard routing table, and goes out the main, non-tunnel interface.

  2. The firewall contains three chains.

    1. The preraw chain is a security measure and drops traffic directed towards the tunnel’s IP not coming from either the tunnel or the local machine.
    2. The postmangle chain checks if the firewall mark Wireguard creates is set, and if so, sets it on the conntrack flow.
    3. The premangle chain checks if the packet is part of a conntrack flow that has a mark set. If so, it sets that mark on the packet. Incoming traffic related to earlier traffic that was marked will now also get marked.

    The last two rules are necessary if strict reverse path forwarding is set and the firewall mark is included in reverse path route lookup. This corresponds to:

    $ sysctl net.ipv4.conf.<interface>.rp_filter
    net.ipv4.conf.<interface>.rp_filter = 1
    
    $ sysctl net.ipv4.conf.<interface>.src_valid_mark
    net.ipv4.conf.<interface>.src_valid_mark = 1
    

    If rp_filter is 0 (turned off) or 2 (loose), or if src_valid_mark = 0, these firewall rules do not need to be set.

  3. The MTU is set to 1420. This is the maximum possible MTU when the Wireguard connection tunnels over IPv6 on an interface with an MTU of 1500.

    1500
      - 40 (IPv6)
      - 8 (UDP)
      - 32 (Wireguard header and trailer)
      = 1420
    

    In case of issues, the MTU can be safely lowered to, for example, 1320. When tunneling over IPv4—which almost always has a 20 bytes header instead of IPv6’s 40 bytes—the MTU can be increased to 1440.

  4. The high-priority routing policy rule checking paths on the main table with SuppressPrefixLength = 0 is for convenience. It tests the destination IP against paths in the main table that have a prefix length other than 0—i.e., any non-default route that matches is taken. This allows outbound traffic to still reach the LAN.

Notes:

  • When uncommenting the DNS server, DNS requests can still go out over the other interfaces. To prevent that, add

      DNSDefaultRoute="yes"
    

    or

      Domains="~."
    

    Be aware that in the current version of systemd-networkd (255.2) this causes a problem if the endpoint is specified as a hostname: the hostname resolve request for that hostname will go out over the offline Wireguard interface and will not receive a response.

    If you want to make sure all traffic headed to the internet always goes through the Wireguard tunnel, see here for possible configurations. Routing rules are not very reliable for that use-case.

  • You can forego explicit configuration of the Wireguard routes when you remove RouteTable="off". You probably still want to add the convenience routing policy rule matching against non-zero prefix routes in the main table. I prefer this more explicit approach.

If/when networkd supports specifying network namespaces, a different solution is possible without routing hacks. See the “The New Namespace Solution” in this article on Wireguard website. There is a request on the systemd GitHub repository for the required functionality.