Skip to main content
P158 BLOGFAX
POST

How to Build a Train Station One VPN at a Time

Like most of us in the software industry, I have a home lab set up at, well, home. In my lab I run various web services and tools for fun, for family, to learn, or for their utility. To save time and ensure my sanity in some cases, I use tools like Ansible, Docker, and in some cases even K3s. Today I am going to walk through installing Flatcar Container Linux on a bare metal VPS, then configuring a WireGuard VPN server.

Things You'll Need

Pouring the Foundation

If you haven't worked with Flatcar Container Linux before, it will feel somewhat awkward. The strangest aspect of the distro is its lack of package manager. This is by design and actually what makes it rather nice to work with in the end. The only tools we will have at our disposal will be those need to run containers, and the idea is that anything else we need will come from a container.

As I mentioned, the idea behind Flatcar Container Linux is that all your dependencies come from containers. This affords us a unique opportunity to leverage this fact and provide all of our host during the installation process. There will be no installing tools or backing up configurations before we make changes. At this point, I would like to note that because VPS providers differ in the exact way in which they handle booting the OS installation media, I won't cover that particular aspect. I refer you to your provider's knowledge base and/or helpdesk.

Once we have booted the installation media, we can begin prepping the installation. To do this, we will be using the butane configuration specification along with the butane config transpiler container to create our host configuration. My VPS provider's interface to the live environment we are currently using, doesn't support copy and paste actions very well, so I will first create my configuration locally using a text editor and then transfer it to the VPS. If you prefer to work directly in the live environment, that is perfectly fine, as the vim editor is available. Copy the following butane config into your editor.

variant: flatcar
version: 1.0.0
storage:
  files:
    # Set hostname
    - path: /etc/hostname
      overwrite: true
      mode: 0644      
      contents:
        inline: train-station
    # Update sshd options, https://www.flatcar.org/docs/latest/setup/security/customizing-sshd/#customizing-sshd-with-a-butane-config
    - path: /etc/ssh/sshd_config
      overwrite: true
      mode: 0600
      contents:
        inline: |
          # Use most defaults for sshd configuration.
          UsePrivilegeSeparation sandbox
          Subsystem sftp internal-sftp
          UseDNS no

          PermitRootLogin no
          AllowUsers core
          AuthenticationMethods publickey
    # Flatcar Container Linux doesn't set any firewall rules by default, so we define some here
    # These rules start by blocking all incoming traffic and allowing all outgoing traffic
    # Then loopback traffic is allowed
    # Next existing connections are allowed, followed by allowing (incoming) traffic on port 22 (SSH)
    # Finally various types of ICMP traffic is allowed, this traffic is useful for troubleshooting
    - path: /var/lib/iptables/rules-save
      mode: 0644
      user:
        name: root
      group:
        name: root
      contents:
        inline: |
          *filter
          :INPUT DROP [0:0]
          :FORWARD DROP [0:0]
          :OUTPUT ACCEPT [0:0]
          -A INPUT -i lo -j ACCEPT
          -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
          -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
          -A INPUT -p icmp -m icmp --icmp-type echo-reply -j ACCEPT
          -A INPUT -p icmp -m icmp --icmp-type destination-unreachable -j ACCEPT
          -A INPUT -p icmp -m icmp --icmp-type echo-request -j ACCEPT
          -A INPUT -p icmp -m icmp --icmp-type time-exceeded -j ACCEPT
          COMMIT
systemd:
  units:
    # Ensure that our default firewall rules are read in at boot time
    - name: iptables-restore.service
      enabled: true
    # Ensure Docker starts automatically instead of being only socket-activated
    - name: docker.service
      enabled: true
passwd:
  users:
    # This is the default user in a Flatcar Container Linux installation
    - name: core
      ssh_authorized_keys:
        # A list of public keys you wish to use for key auth go here
        - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINZCBaBv4Pel8Xf6aBhufccXj2x+R2il7Jri1hICHkHk cskwrd@blog

Don't forget to update the ssh_authorized_keys authorized with the correct keys. After the installation process has completed, the only way to log in will be via SSH. Additionally, you can update the hostname to something more suitable for you. After you are finished modifying the file, save it. I chose to save mine as train-station.bu.

With a butane config in hand, it is time to transpile it into an ignition config. This process is straight forward, and can be done using the following one-liner: cat train-station.bu | docker run --rm -i quay.io/coreos/butane:latest > train-station.ign. It should take less than a minute to complete successfully. At this point, if you have been working locally, you need to transfer the train-station.ign ignition config to the VPS. I did this by copying the config using scp.

The last step of the installation process is almost as simple as a single command. We just need to look up one last piece of information, the path to the disk we want to use. We will use lsblk to help us in our quest for a disk path.

$ lsblk
NAME    MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
sr0      11:0    1   353M  0 rom
vda     253:0    0   200G  0 disk
`-vda1  253:1    0 197.7G  0 part  /

Your output will likely be a tad different, but you are looking for a line item of TYPE disk and a SIZE that matches the disk size you want to install the OS on. In the output above, the path I want is /dev/vda. With the disk path in hand, it is time to execute the installer. To do this, we invoke sudo flatcar-install -d /dev/vda -i ./train-station.ign. The process doesn't take long to complete. Once the installation is complete, reboot and be sure that you are now running from the disk and not the installation media. An easy test can be performed by attempting to connect via SSH, if that succeeds you can move on to Docker and WireGuard, otherwise do a quick cry and repeat the process outlined above.

Building the Rest of the Station

From here on we will move pretty quick. We will use the linuxserver/wireguard container for our WireGuard server. To get started, save the following Compose file somewhere your local Docker client can access with the name docker-compose.yml.

version: '3.4'

networks:
  wgnet:
    name: wgnet
    driver: bridge
    ipam:
     config:
       - subnet: 10.123.0.0/16
         gateway: 10.123.0.1

services:
  wireguard:
    image: lscr.io/linuxserver/wireguard:latest
    container_name: wireguard
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    environment:
      - PUID=${PUID:?UID required}
      - PGID=${PGID:?GID required}
      - TZ=${TZ:-Etc/UTC}
      - SERVERURL=${WG_SERVER_URL:?WireGuard server URL required}
      - SERVERPORT=${WG_SERVER_PORT:-51820}
      - PEERS=${WG_PEER_LIST:?WireGuard peer list required}
      - PEERDNS=1.1.1.1
      - INTERNAL_SUBNET=${WG_INTERNAL_SUBNET:?WireGuard subnet required}
      - ALLOWEDIPS=0.0.0.0/0
      - PERSISTENTKEEPALIVE_PEERS=${WG_PKA_PEER_LIST}
      - LOG_CONFS=true # log client config to logs as QR codes
    networks:
      - wgnet
    volumes:
      - wg-data:/config
      - /lib/modules:/lib/modules
    ports:
      - "${WG_SERVER_PORT:-51820}:51820/udp"
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1

volumes:
  wg-data:

After saving any modification need to fit your network, save a new file named .env in the same directory as the compose file with the following contents.

# General linuxserver.io env vars
PGID=500
PUID=500

# Required WireGuard container env vars
WG_PEER_LIST=thomas,emily
WG_INTERNAL_SUBNET=10.210.0.0
WG_PKA_PEER_LIST=

# Optional WireGuard container env variables
#WG_SERVER_PORT=43210

The .env file will be parsed when we invoke the docker compose command and the variable values will be injected into the resulting compose spec. Double-check the values in the .env file and invoke docker compose -H ssh://core@<train-station-ip> -f docker-compose.yml up -d. Here we use the -H flag to set the Docker endpoint to use. This will securely wrap all communication between the Docker daemon on the VPS and the Docker client we have installed locally. If this is the first time you have made an SSH connection to your VPS, the invocation will fail because the VPS's host key has not yet been trusted yet. The easiest way to fix the issue is to connect to the VPS of SSH before invoking the docker compose command. Now it's time for the grand opening of this shining time station!

Open for Business

Now that we have the operating system installed and the WireGuard server running in a container, we can begin to enjoy the fruits of our labor. My primary WireGuard use case is connecting back to services in my home lab from my phone while on the move. That sort of configuration is worthy of a post by itself, so for now we'll focus on getting an Android phone connected. The maintainers of the WireGuard project have really put in some work on this app. Start by opening the app and tapping the add button. Tap the Scan from QR code option. In your terminal, invoke the following docker -H ssh://core@<train-station-ip> logs wireguard and you should see a few QR codes (the config above configures 2 peers). Scan the code for peer_thomas. After a successful scan, you are prompted to name the tunnel. Keeping with the post's theme, I named the tunnel train-station. Finally, activate the tunnel by tapping the toggle button.

Next Stop, WEB SCALE!

Not really, this setup isn't built for that! As I stated before, the plan is to make some of my self-hosted services available over WireGuard, but that's a post for a different day. Thanks for reading!