Adventures in Rolling Your Own Router: Part VI

In my previous post, I set up an nftables firewall. In this post, I’ll use Ansible to automate the configuration steps we have already performed.

The Purpose of Infrastructure as Code

So far, everything we have done has been manual steps in vim and the CLI. This works fine, but is tedious and error prone. I’m a big proponent of Infrastructure as Code (IaC). It has countless advantages in a corporate environment, and I’ve grown so accustomed to it that it seems to make sense even for my personal network.

Later, I’ll be installing Pi-hole and after reviewing the installer, it looks like it does a lot of stuff I might not want. By defining the router using IaC up to that point, I’ll easily be able to get back to that state if the Pi-hole installer does something I didn’t intend to happen. If I didn’t care to have the IaC definition at the end of the process, I could also accomplish this using VM snapshots. The immediate advantages of having the IaC definition is that I’ll be able to set up the router on real hardware far more quickly (no downtime = happy family) and if I ever want to switch to new hardware I’ll be able to do so quickly and easily.

Setting up Ansible

I’ve only used Ansible a few times, but it was always very straightforward. The documentation, like most documentation from Red Hat, is fantastic.

I started by adding my virtual debrouter to my /etc/ethers and /etc/hosts files on my existing DNS server. I also created an /etc/ansible/hosts file on my Mac and added debrouter to the file. Then, I generated a new RSA key pair on my Mac and installed the public key in the ~/.ssh/authorized_keys file on debrouter. Finally, I set up NOPASSWD for the sudo group, and I am now able to use Ansible to ping debrouter with sudo privileges:

ansible all -m ping -u jmp --become
debrouter.crbj.io | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Building a Playbook

Now, I can go back in time to last week and start translating the manual steps I took into an Ansible playbook. I’ll start a new file called playbook.yml. I’ll also fire up a new Debian 10 VM called debrouter2 we can use for testing the playbook.

The first step is to determine the MAC address of each interface so I can give them customized names extern0 and intern0. We can do this using facts.

Let’s assume that on first boot only one of the two interfaces will be connected to Ethernet and have gotten an IPv4 address via DHCP. This interface will be our extern0 interface, and we should be able to find it using the default_ipv4 entry in the ansible_facts object.

In real life, my router hardware actually has three interfaces, so I may end up just supplying the interface names manually.

Note that I’m using set_fact instead of vars because I only want these filters to be evaluated once.

---
- name: Router Setup
  hosts: debrouter2.crbj.io

  pre_tasks:
  - set_fact:
      ifs: "{{ ansible_facts.interfaces | reject('equalto', 'lo') }}"
  - set_fact:
      external_if: "{{ ansible_facts.default_ipv4.interface }}"
  - set_fact:
      internal_if: "{{ ifs | reject('equalto', external_if) | first }}"
  - set_fact:
      external_if_macaddress: "{{ ansible_facts[external_if].macaddress }}"
  - set_fact:
      internal_if_macaddress: "{{ ansible_facts[internal_if].macaddress }}"

  tasks:
  - name: Print external interface MAC address
    ansible.builtin.debug:
      var: external_if_macaddress
  - name: Print internal interface MAC address
    ansible.builtin.debug:
      var: internal_if_macaddress

Now, we need templates for our .link files.

% cat templates/link.j2
[Match]
MACAddress={{ macaddress }}

[Link]
Name={{ ifname }}

Now we can use the template module to automatically place the link files on the router.

  - name: Set external interface name
    ansible.builtin.template:
      src: templates/link.j2
      dest: /etc/systemd/network/10-extern0.link
      owner: root
      group: root
      mode: '0644'
    vars:
      macaddress: "{{ external_if_macaddress }}"
      ifname: "extern0"
  - name: Set internal interface name
    ansible.builtin.template:
      src: templates/link.j2
      dest: /etc/systemd/network/20-intern0.link
      owner: root
      group: root
      mode: '0644'
    vars:
      macaddress: "{{ internal_if_macaddress }}"
      ifname: "intern0"

Next, we add some templates for our .network files.

% cat templates/dhcp.network.j2
[Match]
Name={{ ifname }}

[Network]
DHCP=ipv4
% cat templates/static.network.j2
[Match]
Name={{ ifname }}

[Network]
Address={{ address_with_netmask }}
IPForward=true

The templates are again fed to the template module.

  - name: Set external network configuration
    ansible.builtin.template:
      src: templates/dhcp.network.j2
      dest: /etc/systemd/network/external.network
      owner: root
      group: root
      mode: '0644'
    vars:
      ifname: "extern0"
  - name: Set internal network configuration
    ansible.builtin.template:
      src: templates/static.network.j2
      dest: /etc/systemd/network/internal.network
      owner: root
      group: root
      mode: '0644'
    vars:
      ifname: "intern0"
      address_with_netmask: "192.168.100.1/24"

Now, we can use the systemd module to disable networking.service and enable systemd-networkd. I’m going a step further here and actually masking networking.service so it’s not possible to accidentally enable it later.

    - name: Completely disable the default networking.service
      ansible.builtin.systemd:
        name: networking.service
        state: stopped
        enabled: no
        masked: yes
    - name: Enable systemd-networkd.service
      ansible.builtin.systemd:
        name: systemd-networkd.service
        state: started
        enabled: yes
        masked: no

We also need to restart the host at this point. Check out the final playbook on GitHub to see how this is done.

Now we need to install nftables and remove iptables.

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: yes
    - name: Remove iptables
      ansible.builtin.apt:
        name: iptables
        purge: yes
        state: absent
    - name: Install nftables
      ansible.builtin.apt:
        name: nftables
        state: latest
    - name: Enable nftables.service
      ansible.builtin.systemd:
        name: nftables.service
        state: started
        enabled: yes
        masked: no

Finally, we can install the firewall rules using another call to the template module.


    - name: Set up nftables rules
      ansible.builtin.template:
        src: templates/nftables.conf.j2
        dest: /etc/nftables.conf
        owner: root
        group: root
        mode: '0644'
      vars:
        internal_ifname: "intern0"
        external_ifname: "extern0"
        lan_network: "192.168.100.0/24"

At this point, we should have a working router. In the next post, I’ll attempt to install Pi-hole without messing up anything we have done so far.