Setting up an IPSec tunnel using Libreswan

I was interested on how to configure IPSec connections between linux machines, mostly for having another tool in my belt, because sometimes we deploy virtual machines on customer’s infrastructure and not all services are TLS enabled, and IPSec is a way of keeping prying eyes away.

Configuring an IPSec connection using libreswan is well documented on Red Hat’s Securing Networks guide, so I wanted to raise the bar with two extra objectives: use x509 certificates and doing almost all the process with ansible.

This time I wanted to practice with ansible roles, and I created one from scratch, it ended with the following structure:

|-- roles
|   `-- setup_ipsec
|       |-- default
|       |   `-- main.yml
|       |-- handlers
|       |   `-- main.yml
|       |-- tasks
|       |   `-- main.yml
|       `-- templates
|           `-- site2site-x509.conf.j2

Installing packages

The IPSec setup required the installation of the libreswan package, but, as I was using the CentOS 8 vagrant box as base, I also made sure all packages were updates.

- name: update all packages
  package:
    name: "*"
    state: latest

- name: install libreswan
  package:
    name: libreswan
    state: installed

As the previous week I deployed nginx with SSL certificates generated using ansible I wanted to use that knowledge again, so I installed the cryptology related packages on the target machine.

- name: install cryptography module
  package:
    name: python3-cryptography
    state: installed

- name: install python-openssl module
  package:
    name: python3-pyOpenSSL
    state: installed

pyOpenSSL is required to handle PKCS12 Files, the other community.crypto modules could use it, but as it was going to be removed as backend in the 2.0 release of community.crypto, I preferred to use the cryptography mode when possible.

Generating the certificates

This part was borrowed from my previous post, but I made a big change, this time I used the ownca provider instead of generating a self signed certificate. So the first tasks was to generate a Certificate Authority on the control node, I found a concise guide to do this in Lorenzo Fontana’s gist

openssl genrsa -des3 -out rootCA.key 4096
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1024 -out rootCA.crt

Once I had it I put the private key, the certificate and the password on a vault file to keep them secure.

The corresponding tasks were:

- name: generate a private key
  community.crypto.openssl_privatekey:
    path: "{{ privatekey_path }}"
    backup: yes

- name: generate a CSR
  community.crypto.openssl_csr:
    common_name: "{{ position }}"
    country_name: "{{ cert_country_name }}"
    locality_name: "{{ cert_locality_name }}"
    state_or_province_name: "{{ cert_state_name }}"
    organization_name: "{{ cert_organization_name }}"
    organizational_unit_name: "{{ cert_ou_name }}"
    privatekey_path: "{{ privatekey_path }}"
    subject_alt_name: "{{ ['DNS:' +  ansible_facts['fqdn'], 'DNS:' + position ] + ansible_facts['all_ipv4_addresses'] | map('regex_replace', '^', 'IP:') | list }}"
    subject:
      CN: "{{ position }}"
    path: "{{ csr_path }}"
    backup: yes

- name: generate a certificate
  community.crypto.x509_certificate:
    path: "{{ certificate_path }}"
    privatekey_path: "{{ privatekey_path }}"
    csr_path: "{{ csr_path }}"
    provider: ownca
    ownca_content: "{{ ownca_content }}" 
    ownca_privatekey_content: "{{ ownca_privatekey_content }}"
    ownca_privatekey_passphrase: "{{ ownca_privatekey_passphrase }}"

Configuring IPSec service

Once I had the certificates in place, it was time to configure the IPSec service, the first step was dealing with the certificates. For managing certificates with certool and pk12tool I needed the root ca certificate and the host certificate in pkcs12 format as files in the target machines.

- name: copy root CA
  copy:
    content: "{{ ownca_content }}"
    dest: "{{ ca_path }}"
  notify: import rootca

- name: generate a pkcs12 file
  community.crypto.openssl_pkcs12:
    certificate_path: "{{ certificate_path }}"
    privatekey_path: "{{ privatekey_path }}"
    other_certificates: "{{ ca_path }}"
    passphrase: "{{ privatekey_passphrase }}"
    friendly_name: "{{ position }}"
    path: "{{ pkcs12_path }}"
    backup: yes
  notify: import pkcs12

- name: flush handlers
  meta: flush_handlers 

I imported the certificates in the NSS library as handlers in order to only do the import process when the certificates changed, because I used the command module and it would be executed every time the playbook is run.

- name: import rootca
  command:
    cmd: "certutil -d sql:/etc/ipsec.d -A -n rootCA -t C,C,p -a -i {{ ca_path }}"

- name: import pkcs12
  command: 
    cmd: "pk12util -d sql:/etc/ipsec.d -i {{ pkcs12_path }} -W {{ privatekey_passphrase }}"

Then I used the template module to create a configuration file:

- name: copy configuration file
  template:
    src: ./templates/site2site-x509.conf.j2
    dest: /etc/ipsec.d/site2site-x509.conf
  notify: restart ipsec

The jinja2 template content was:

conn {{ subnetname }}
    also={{ tunnelname }}
    leftsubnet={{ leftsubnet }}
    rightsubnet={{ rightsubnet }}

conn {{ tunnelname }}
    {% if position == 'left' -%}
      leftcert={{ leftcert }}
    {%- else -%}
      rightcert={{ rightcert }}
    {%- endif %}

    leftid={{ leftid }}
    left={{ leftip }}
    rightid={{ rightid }}
    right={{ rightip }}
    auto={{ autostart }}

For enabling the IPSec service on I used the service_facts module to know if firewalld was running, because using immediate when the service is stopped, would cause a failure.

- name: gather service facts
  service_facts:

- name: enable firewalld services
  firewalld:
    service: ipsec
    state: enabled
    immediate: "{{ ('firewalld.services' in ansible_facts['services'] and ansible_facts['services']['firewalld.service']['state']=='running')|bool }}"
    permanent: yes

Time to go

Once I had all the tasks in the role, it was time to run it, I used it on a playbook with a some extra tasks to have a way of testing it.

---
- name: Configure site-to-site vpn
  hosts: ipsec
  vars:
    leftdev: eth2
    leftsrc: 192.168.131.2
    righttgt: 192.168.132.2
  roles:
    - setup_ipsec
  tasks:
    - name: restart ipsec
      service:
        name: ipsec
        state: restarted
        enabled: yes
    - name: install httpd for testing
      package:
        name: httpd
        state: present
      when: position == 'right'
    - name: start and enable httpd
      service:
        name: httpd
        state: started
        enabled: yes
      when: position == 'right'
    - name: create an hello world message
      copy:
        content: "Hello world!"
        dest: /var/www/html/hello.txt
        owner: apache
        group: apache
        mode: 0644
      when: position == 'right'
      tags: test
    - name: create a route to the right
      command:
        cmd: "ip route add {{ rightsubnet }} dev {{ leftdev }} src {{ leftsrc }}"
      when: position == 'left'
      ignore_errors: yes
    - name:  get from the http server on the right side
      get_url:
        url: "http://{{ righttgt }}/hello.txt"
        dest: /tmp/hello.txt
        use_proxy: no
        force: yes
      register: httpresult
      when: position == 'left'
      tags: test
    - name: show http result
      debug:
        var: httpresult
      when: httpresult is defined
      tags: test
    - name: cleanup
      file:
        path: /tmp/hello.txt
        state: absent
      tags: test

A footnote about the variables

As I was creating a role, I made extensive use of variables, like in any other role, they can be found on the defaults/main.yml file.

Some of them were related to the ipsec configuration, I used the left/right values suggested on Red Hat’s documentation.

subnetname: mysubnet
tunnelname: mytunnel
leftsubnet: 192.168.131.0/24
rightsubnet: 192.168.132.0/24
autostart: start

leftcert: left
leftid: left
leftip: 10.200.200.101

rightcert: right
rightid: right
rightip: 10.200.200.102

There were a bunch of them for the certificate generation, they were the same of my previous post.

csr_base_path: /etc/pki/tls/csr
privatekey_path: /etc/pki/tls/private/{{ position }}.key
privatekey_passphrase: overrideme
csr_path: "{{ csr_base_path }}/{{ position }}.csr"
certificate_path: /etc/pki/tls/certs/{{ position }}.crt
ca_path: "/etc/pki/tls/certs/rootca.pem"
pkcs12_path: /etc/pki/tls/certs/{{ position }}.p12

cert_country_name: "ES"
cert_locality_name: "Albal"
cert_state_name: "Valencia"
cert_organization_name: "Garmo Labs Inc"
cert_ou_name: "Automation"

Ansible documentation recommends against using variables from vault files directly for readability reasons, and also, nobody distributes vault files with their roles, so the best practice is copying them.

# Get sensitive variables from vault file
ownca_content: "{{ cacertificate }}"
ownca_privatekey_content: "{{ caprivkey }}"
ownca_privatekey_passphrase: "{{ capass }}"

The position variable was defined as a host variable on my inventory file, so I had one host on the left and one on the right.

The complete lab

I made all the files available at my home lab git repo