VPN kill switch: how to do it on Linux
Tags: linux, security, devops
Published: 2024-01-02 12:00:00

[Kill switch](https://en.wikipedia.org/wiki/Kill_switch) is a mechanism that prohibits any outgoing traffic unless a VPN is active. In this article we discuss how to implement such a mechanism using Linux [policy-based routing](https://en.wikipedia.org/wiki/Policy-based_routing) for a wide range of VPNs.


# Table of contents

- [Linux IP packet routing tour](#routing).
    - [CIDR notation, gateways and broadcast addresses](#cidr).
    - [How to read the routing table](#routing-table).
    - [Is there only one routing table?](#is-there-only-one-routing-table).
    - [Policy-based routing](#policy-based-routing).
- [VPN kill switch with policy-based routing](#kill-switch).
    - [Custom routing table](#custom-routing-table).
    - [Custom routing rules](#custom-routing-rules).
    - [Any alternatives?](#any-alternatives)
    - [Multiple VPNs](#multiple-vpns).
- [Conclusion](#conclusion).


# Linux IP packet routing tour<a name="routing"></a>

![Photo by Robin Pierre on Unsplash.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bcwf5rplfbp0ygnldhl3.jpg)

Before diving into how to implement a kill switch we need to get familiar with how Linux IP packet routing works in general. The best way to do that is to run `ip route` command that shows a routing table. On my computer this command outputs the following.

```bash
🌊 ip route
default via 10.65.0.1 dev wlan0 proto dhcp src 10.65.0.11 metric 305
10.33.0.0/16 dev vpn1 scope link
10.65.0.0/20 dev wlan0 proto dhcp scope link metric 305
```

## CIDR notation, gateways and broadcast addresses<a name="cidr"></a>

Each row in the table is a rule that matches a particular packet destination in [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation). For example, _10.33.0.0/16_ matches any packet with a destination _10.33.X.X_ where _X_ is arbitrary number from 0 to 255. Usually the first address _10.33.0.1_ is the gateway — default packet destination if no rules match the current packet destination — and the last address _10.33.255.255_ is broadcast address — if you send a packet to this address all nodes in the network will receive it. The reality is more complicated though: you can use any address as the gateway and set any address as broadcast address. Plus broadcast packets are usually only sent to the nodes that are connected to the same network switch and these packets are usually blocked by the network router to prevent accidental flooding.


## How to read the routing table<a name="routing-table"></a>

Now we can go back to the table to study the rules. In the output default is another way of spelling _0.0.0.0/0_, and this rule matches any packet destination.

- The first rule says «forward the packet to a gateway with address _10.65.0.1_ if none of other rules match».
- The second rule says «forward the packet to network device vpn1 if the destination matches _10.33.0.0/16_». In this case the device driver or a program that is attached to this device will handle the packet.
- The third rule says «forward the packet to network device _wlan0_ if the destination matches _10.65.0.0/16_». There is no gateway in this rule because the packet's destination is in the same network as the gateway, and Linux sends the packet directly to the destination.


## Is there only one routing table?<a name="is-there-only-one-routing-table"></a>

As you may have guessed there are many routing tables in the system. There is default, local and main table. Each table has the id and the name. The mapping between them is stored in `/etc/iproute2/rt_tables` file. Counterintuitively the default table is main. To see the contents of other tables use the following commands.

```bash
🌊 ip route show table main
...
🌊 ip route show table local
...
🌊 ip route show table default
...
```

On my computer default table does not exist. The local table lists local and broadcast addresses associated with network devices.

```bash
🌊 ip route show table local
local 10.33.0.41 dev vpn1 proto kernel scope host src 10.33.0.41
local 10.65.0.11 dev wlan0 proto kernel scope host src 10.65.0.11
broadcast 10.65.15.255 dev wlan0 proto kernel scope link src 10.65.0.11
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
```

## Policy-based routing<a name="policy-based-routing"></a>

Linux has another set of rules that define how the table is selected. These rules also have priorities, so if a packet matches multiple rules then the rule with lowest priority is selected. To see all the rules use `ip rule` command.

```bash
🌊 ip rule
0:      from all lookup local
32766:  from all lookup main
32767:  from all lookup default
```

On my computer each rule matches any packet (_from all_ clause in the output), and _local_ table has lower priority than _main_. This is what we will leverage to create a VPN kill switch.


# VPN kill switch with policy-based routing<a name="kill-switch"></a>

![Photo by kimi lee on Unsplash](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vifyoh5pt19ps6kjj7m8.jpg)

VPN kill switch requires routing all outgoing traffic through a VPN except for the local traffic and VPN internal traffic. This means that if a VPN uses port 1234, then the traffic from this port should go through the default gateway or directly to the node in the local network. To implement that we will create a separate table and a rule that uses this table for all non-VPN and non-local packets.


## Custom routing table<a name="custom-routing-table"></a>

First edit `/etc/iproute2/rt_tables` file and add the following line that defines our new table.

```
83 vpn1
```

Now create rules in the new table. The table itself is created automatically.

```bash
# remove existing rules if any
🌊 ip route flush table vpn1
# add default route via gateway node from VPN network
🌊 ip route add default dev vpn1 via 10.83.0.1 table vpn1 metric 100
# add blackhole route (this is the actual kill switch)
🌊 ip route add blackhole default table vpn1 metric 200
# check that the rule has been added
🌊 ip route show table vpn1
default via 10.83.0.1 dev vpn1 metric 100
blackhole default metric 200
```

To summarize, we added new routing table called _vpn1_, we added default route via gateway node from VPN and we added so-called blackhole route. Default route is preferred over blackhole route because of the lower metric. The blackhole route is used only when the default is not present in the table. Device _vpn1_ is automatically deleted whenever VPN is stopped and the corresponding rules are deleted as well, however, blackhole route stays intact.


## Custom routing rules<a name="custom-routing-rules"></a>

Now we will link _vpn1_ to the _main_ routing table.

```bash
# route all packets except the ones from source port 1234 using the rules from table vpn1
🌊 ip rule add not sport 1234 table vpn1
# prefer specific rules in table "main" over the rules in other tables
🌊 ip rule add table main suppress_prefixlength 0
# check the rules
🌊 ip rule
0:      from all lookup local
32764:  from all lookup main suppress_prefixlength 0
32765:  not from all sport 9376 lookup vpn1
32766:  from all lookup main
32767:  from all lookup default
```

The first rule is self-explanatory, you can check out all possible alternatives to _not sport_ in the [documentation](https://man7.org/linux/man-pages/man8/ip-rule.8.html). According to the documentation _suppress_prefixlength N_ option means «reject routing decisions that have a prefix length of _N_ or less». Prefix length equals zero means default route, hence this rule means «reject routing decisions that match default route in table _main_». So, _suppress_prefixlength 0_ is a fancy way of saying «ignore the default route from the main routing table». Since the next table in the list is _vpn1_, then all the traffic except for local networks will go through the _vpn1_ network interface.


## Any alternatives?<a name="any-alternatives"></a>

We tested policy-based VPN kill switch with Wireguard (do not forget to specify the port in the configuration) and Staex. Both VPNs use only one port for their internal traffic. It should be possible to match the traffic of a centralized VPN by source/destination in CIDR notation (_from_ and _to_ options of [`ip rule`](https://man7.org/linux/man-pages/man8/ip-rule.8.html) command). In general the exact packets can be marked using _iptables_ and then matched by the same mark in the routing rules (see [mark](https://man7.org/linux/man-pages/man8/iptables-extensions.8.html) _iptables_ module). This [article](https://www.wireguard.com/netns/) discusses various approaches within the context of Wireguard.


## Multiple VPNs<a name="multiple-vpns"></a>

The nature of a kill switch does not play well with multiple VPNs. Probably the only way to exclude multiple ports from the default route is to use firewall marks. We have not evaluated this approach yet.


# Conclusion<a name="conclusion"></a>

![Photo by Denys Nevozhai on Unsplash.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9p47f07z7ne76gutufmo.jpg)

We conceived kill switch to be a simple VPN feature, however, we underestimated the complexity of Linux networking. Linux has multiple layers of IP packet routing rules, built-in firewall and network namespaces. VPNs do not make this task simpler either: they might use several ports for the internal communication or you might want to run multiple VPNs on a single node.

Kill switch will be available in the upcoming Staex release. [Subscribe to our newsletter](https://staex.io/blog/subscribe) to be notified about the new releases.

_We at Staex help our clients make IoT devices first-class citizens in their private networks, protect from common attacks, reduce mobile data usage, and enable audacious use cases that were not possible before. To learn more about our product please visit [this page](https://staex.io/product?utm_source=devto&utm_medium=article&utm_campaign=2024-01-03)._