Headscale deployment on Fedora 37

Hi there,

Today, I want to try and go through the deployment of the Headscale control server. This is an open-source implementation of Tailscale, a commercial VPN service.

Here is an explanation from the Headscale documentation.

Tailscale is a modern VPN built on top of Wireguard. It works like an overlay network between the computers of your networks – using NAT traversal.

The control server works as an exchange point of Wireguard public keys for the nodes in the Tailscale network. It assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.

https://github.com/juanfont/headscale

For the OS I will use Fedora 37, but it should also work on CentOS / Rocky Linux.

Download and configuration of Headscale

First, we need to get the Headscale binary from GitHub. As far as I am aware, there is no repository or RPM package for Fedora.

For this, we use “wget”, if you do not have it already on your system you can install it with this command.

headscale :: ~ » sudo dnf install wget -y

Download, create files, and service user

Let’s get the binary. I will use the official documentation for this guide, by the way.
Version 0.21.0 was the latest at the time of writing.

headscale :: ~ » sudo wget --output-document=/usr/local/bin/headscale https://github.com/juanfont/headscale/releases/download/v0.21.0/headscale_0.21.0_linux_amd64

This will download the binary, move and rename it to “/usr/local/bin/headscale”.

Next, we make it executable.

headscale :: ~ » sudo chmod +x /usr/local/bin/headscale

Create a directory for the headscale configuration file and the SQLite database. For the latter, we will create a user, that uses the folder as a home dir.

headscale :: ~ » sudo mkdir -p /etc/headscale
headscale :: ~ » sudo useradd --create-home --home-dir /var/lib/headscale/ --system --user-group --shell /usr/bin/nologin headscale

Now we need to create an empty SQLite database and configuration file.

headscale :: ~ » sudo touch /var/lib/headscale/db.sqlite
headscale :: ~ » sudo touch /etc/headscale/config.yaml

The configuration file

There is an example configuration on the GitHub site. It is recommended to modify and use this as a base.

At a minimum, we need to change “server_url”, “listen_addr”, “db_path” and “unix_socket” and add “private_key_path”. If you want to, you can adjust the “ip_prefixes” and “nameservers”. I set the “override_local_dns” to false since it caused issues in my setup.

headscale :: ~ » sudo vim /etc/headscale/config.yaml
...
server_url: http://vpn.example.de:8080
private_key_path: /var/lib/headscale/private.key
listen_addr: 0.0.0.0:8080

db_path: /var/lib/headscale/db.sqlite
unix_socket: /var/run/headscale/headscale.sock

ip_prefixes: 10.32.0.0/16
override_local_dns: false

Ok, now we set the permissions.

headscale :: ~ » sudo chown headscale:headscale /etc/headscale -R
headscale :: ~ » sudo chown headscale:headscale /var/lib/headscale -R

Now, we could start the server. But I want to create a systemd service file first.

For this, create a file in “/etc/systemd/system/” named “headscale.service” and paste the following into it. If you use different folders for headscale, adjust the “WorkingDirectory” and the “ReadWritePaths”.

Start the Service

Reload the systemd daemon, start, and enable the service.

headscale :: ~ » sudo systemctl daemon-reload
headscale :: ~ » sudo systemctl start headscale.service
headscale :: ~ » sudo systemctl enable headscale.service

To check the status, use this command.

headscale :: ~ » sudo systemctl status headscale.service

That’s it for the server configuration. Next, we need to create a user and add a few clients.

User creation and Client Registration

Create user

Let’s create a few users. These will be used to assign to the clients later.

headscale :: ~ » headscale users create client1
User created
headscale :: ~ » headscale users create client2
User created
headscale :: ~ » headscale users create client3
User created

With the “list” option, you can list all users we created.

headscale :: ~ » headscale users list
ID | Name      | Created            
1  | client1   | 2023-04-02 16:39:35
2  | client2   | 2023-04-02 16:41:00
3  | client3   | 2023-04-02 16:42:24

Install tailscale client

Now we can register a device, but first, we need the client application. There is a repository for tailscale. Add it with “dnf config-manager” and install the client.

client1 :: ~ » sudo dnf config-manager --add-repo https://pkgs.tailscale.com/stable/fedora/tailscale.repo
client1 :: ~ » sudo dnf install tailscale -y

Start the tailscale service.

client1 :: ~ » sudo systemctl start tailscaled.service

Register Client

Now we can register the client. Use the “–login-server” option to specify our newly created server.

client1 :: ~ » sudo tailscale up --login-server http://vpn.example.de:8080

This will give you a link that looks something like this.

client1 :: ~ » 

To authenticate, visit:

        http://vpn.example.de:8080/register/nodekey:3515c2245337dsfjl30a37a345c0eaaa2e5jd93las9230e56459a33

If you open this in your browser, you will get a command that you have to execute on the headscale server.

Replace “USERNAME” with the actual username you want to use and execute it.

headscale :: ~ » headscale nodes register --user client1 --key nodekey:123442352345266037afds5hsdfg2dfg0eaedd40aa2e5a5e3adb8cb9e0ed23dgg4w4sdfa33
Machine notebook-client1 registered

Once executed successfully, the client should get a “Success” message and be connected to headscale.

To check the status on the headscale server, execute the following command. I also added a couple of devices to test the connection.

Testing the connection

Great, let’s test the connectivity.

For the tests, I will use client3 and try to ping client2. These devices are in my homelab, but on different networks.

# Client2
client2 :: ~ » ifconfig enp1s0
enp1s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.10.0.103  netmask 255.255.255.0  broadcast 10.10.0.255
        inet6 fe80::e722:ab5f:468d:a7ca  prefixlen 64  scopeid 0x20<link>
        ether 00:00:00:69:00:94  txqueuelen 1000  (Ethernet)
        RX packets 402284  bytes 103860335 (99.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 659104  bytes 6604493862 (6.1 GiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

client2 :: ~ » ifconfig tailscale0
tailscale0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1280
        inet 10.32.0.2  netmask 255.255.255.255  destination 10.32.0.2
        inet6 fd7a:115c:a1e0::2  prefixlen 128  scopeid 0x0<global>
        inet6 fe80::766e:2f5b:d9bf:8f3d  prefixlen 64  scopeid 0x20<link>
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 500  (UNSPEC)
        RX packets 20  bytes 1680 (1.6 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 36  bytes 2536 (2.4 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
# Client3
client3 :: ~ » ifconfig enp1s0
enp1s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.10.200.102  netmask 255.255.255.0  broadcast 10.10.200.255
        inet6 fe80::5054:ff:fee6:d196  prefixlen 64  scopeid 0x20<link>
        ether 00:00:00:e6:00:96  txqueuelen 1000  (Ethernet)
        RX packets 6914  bytes 3033110 (3.0 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 6532  bytes 872299 (872.2 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

client3 :: ~ » ifconfig tailscale0
tailscale0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1280
        inet 10.32.0.3  netmask 255.255.255.255  destination 10.32.0.3
        inet6 fd7a:115c:a1e0::3  prefixlen 128  scopeid 0x0<global>
        inet6 fe80::28f3:6b75:ec83:6a6c  prefixlen 64  scopeid 0x20<link>
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 500  (UNSPEC)
        RX packets 5  bytes 420 (420.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 29  bytes 2112 (2.1 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

Let’s try the connection. First, I will ping the local IP address to verify that client3 cannot reach client2.

client3 :: ~ » ping 10.10.0.103
PING 10.10.0.103 (10.10.0.103) 56(84) bytes of data.
^C
--- 10.10.0.103 ping statistics ---
19 packets transmitted, 0 received, 100% packet loss, time 18433ms

Alright. Next, the tailscale interface.

client3 :: ~ » ping 10.32.0.2
PING 10.32.0.2 (10.32.0.2) 56(84) bytes of data.
64 bytes from 10.32.0.2: icmp_seq=1 ttl=64 time=3.18 ms
64 bytes from 10.32.0.2: icmp_seq=2 ttl=64 time=2.62 ms
64 bytes from 10.32.0.2: icmp_seq=3 ttl=64 time=2.53 ms
64 bytes from 10.32.0.2: icmp_seq=4 ttl=64 time=2.67 ms
64 bytes from 10.32.0.2: icmp_seq=5 ttl=64 time=2.66 ms
^C
--- 10.32.0.2 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4006ms
rtt min/avg/max/mdev = 2.526/2.729/3.178/0.230 ms

The ping went through immediately, but this is because I already tested it before. The initial connection could take a couple of seconds, which means on the first try I lost a few pings. But after the initial connection, it works quite nicely.

Route advertisement

There is actually the option to advertise the routes from a client. Let’s go over that quickly.

I will advertise the routes from client2. “–advertise-exit-node” advertises the client2 as a default gateway.

# Advertise routes
client2 :: ~ » sudo tailscale up --login-server http://vpn.example.de:8080 --advertise-exit-node --advertise-routes=10.10.0.0/24

# Allow IP forwarding
client2 :: ~ » echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
client2 :: ~ » echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
client2 :: ~ » sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

# Add masquerading (only if you use firewalld)
client2 :: ~ » sudo firewalld --add-masquerade --permanent

Now we need to enable the routes on the Headscale server. First, let’s check if they actually show up.

headscale :: ~ » headscale routes list
ID | Machine | Prefix       | Advertised | Enabled | Primary
1  | client2    | ::/0         | true       | false   | -      
2  | client2    | 10.10.0.0/24 | true       | false   | false   
3  | client2    | 0.0.0.0/0    | true       | false   | -      

Ok, they show up. Let’s enable the route with the prefix 10.10.0.0/24. We use the ID to specify the route in the command.

headscale :: ~ » headscale routes enable -r 2

Check it again.

headscale :: ~ » headscale routes list
ID | Machine | Prefix       | Advertised | Enabled | Primary
1  | client2    | ::/0         | true       | false   | -      
2  | client2    | 10.10.0.0/24 | true       | true    | true   
3  | client2    | 0.0.0.0/0    | true       | false   | -      

On client3, we accept the routes.

client3 :: ~ » sudo tailscale up --login-server http://vpn.example.de:8080 --accept-routes

To check if the routes are actually accepted, we can use the “ip” command. Tailscale creates a separate table for its routes. To show the different rules, use “ip rule show”.

client3 :: ~ » ip rule show
0:      from all lookup local
5210:   from all fwmark 0x80000/0xff0000 lookup main
5230:   from all fwmark 0x80000/0xff0000 lookup default
5250:   from all fwmark 0x80000/0xff0000 unreachable
5270:   from all lookup 52
32766:  from all lookup main
32767:  from all lookup default

I know that in my case, tailscale uses 52 as an ID for the table, but I don’t know if it’s always the case. Anyway, to check the actual route, use the “ip show table” command.

client3 :: ~ » ip route show table 52
10.10.0.0/24 dev tailscale0
10.32.0.1 dev tailscale0
10.32.0.2 dev tailscale0
100.100.100.100 dev tailscale0

Here we can see that the internal network “10.10.0.0/24” was advertised.

I might try a few of the other “overlay network” types of VPN applications, like netmaker or innernet, just to have a comparison. I think that could be fun.

Anyway. That’s it.

Till next time.

Leave a Reply