Nginx Proxy with a self signed certificate

Last week I deployed a nodejs using ansible, this time I’ve put a nginx server with a self signed certificate as a reverse proxy in front of it.

Using a commercial certificate would be as easy as copying a couple of files, setting up a Let’s Encrypt certificate would have been a good exercise, but it would have required using a domain, and allowing incoming connections.

I was aware there were several roles in the galaxy that can do the job, but the objective is to do it myself, and some of them used command or shell modules which I preferred to avoid.

Generating the certificate

This is a multi-step process, the main ones are:

  1. Generate a private key: community.crypto.openssl_privatekey
  2. Generate a Certificate Signing Request: community.crypto.openssl_csr
  3. Self-sign the request community.crypto.x509_certificate

For those steps, the python cryptography module was required, thus the first step was ensuring it was. I also wanted to be sure I had a directory for storing the csr.

    - name: install cryptography module
      package:
        name: python3-cryptography
        state: installed
      tags: cert
    - name: create a directory for csr
      file:
        path: "{{ csr_base_path }}"
        state: directory
      tags: cert

And of course, the modules, I installed them using ansible galaxy:

ansible-galaxy collection install community.crypto

Once the prerequisites were met, the first step was generating a private key using community.crypto.openssl_privatekey:

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

This task generated a 4096 bit private key.

Then I generated the CSR using the community.crypto.openssl_csr module:

    - name: generate a CSR
      community.crypto.openssl_csr:
        common_name: "{{ ansible_facts['fqdn'] }}"
        country_name: "ES"
        locality_name: "Valencia"
        state_or_province_name: "Valencia"
        organization_name: "Home lab Inc"
        organizational_unit_name: "Automation"
        privatekey_path: "{{ private_key_path }}"
        subject_alt_name: "{{ ['DNS:' +  ansible_facts['fqdn'] ] + ansible_facts['all_ipv4_addresses'] | map('regex_replace', '^', 'IP:') | list }}"
        path: "{{ csr_path }}"
        backup: yes
      tags: cert

I got the common name from the ansible facts ansible_fqdn instead of using ansible_hostname or inventory_hostname for two reasons: first because for validating the certificate a complete fqdn is recommended, and second, the inventory hostname can be a friendly name which is unknown to the network. The subject alternative name was generated dynamically using the ip addresses on the facts and a couple of filters, the most complicated was understanding how to use the curly braces correctly in the expression. It’s not recommended for public facing services.

Then the third part, the self signed certificated.

    - name: generate a certificate
      community.crypto.x509_certificate:
        path: "{{ certificate_path }}"
        privatekey_path: "{{ private_key_path }}"
        csr_path: "{{ csr_path }}"
        provider: selfsigned
      tags: cert

No much secret on this part, but the module has the ability to use other providers than selfsigned for instance, to get Let’s Encrypt certificates.

Installing nginx

The procedure to install nginx has no secrets, the classic install a service procedure, see the details at the bottom.

The configuration was made in two tasks:

  1. Redirect http traffic to https
  2. Configure an https server

I took advantage of the default configuration’s include directory structure and I put the redirect on /etc/nginx/default.d/redirect_https.conf as it was a single line I used the copy module:

    - name: setup redirect to https
      copy:
        content: 'return 301 https://$host$request_uri;'
        dest: /etc/nginx/default.d/redirect_https.conf

For the site configuration I used the template module, as the file had to include the paths for the certificate and private key files.

    - name: setup https server
      template:
        src: ./templates/https_server.conf.j2
        dest: /etc/nginx/conf.d/https_server.conf

The template contents were:

# Settings for a TLS enabled server.

    server {
        listen       443 ssl http2 default_server;
        listen       [::]:443 ssl http2 default_server;
        server_name  _;
        root         /usr/share/nginx/html;

        ssl_certificate "{{ certificate_path }}";
        ssl_certificate_key "{{ private_key_path }}";
        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout  10m;
        ssl_ciphers PROFILE=SYSTEM;
        ssl_prefer_server_ciphers on;

        location / {
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
	    proxy_pass http://localhost:3000;
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }

Enabling connections in firewalld

Once installed, I wanted to allow incoming connections to the http and https ports, the firewalld module is very easy, but it fails when the module is no not active, so I had to check it first. The builtin module service facts allowed me to get service status as facts.

    - name: discover service facts
      service_facts:
      tags: firewall

    - name: open and https ports
      firewalld:
        service: "{{ item }}"
        state: enabled
      loop: [ http, https]
      when: ansible_facts['services']['firewalld.service']['state'] == "running"
      tags: firewall

I used the facts as a condition in the when clause.

Enabling proxied connections

As SELinux prevents the web server to use outgoing connections, so in order to allow connections to the nodejs process running as backend, the selinux boolean httpd_can_network_connect should be connected.

But in order to use the ansible.posix.seboolean the libsemanage-python library should be installed on the target machine, so I installed it.

    - name: install libsemanage-python
      package:
        name: python3-libsemanage
        state: installed
      when: ansible_selinux is defined and ansible_selinux != False and ansible_selinux.status == 'enabled'
      tags: selinux

Then I activated the boolean.

    - name: enable SELinux network connect
      ansible.posix.seboolean:
        name: httpd_can_network_connect
        state: yes
        persistent: yes
      when: ansible_selinux is defined and ansible_selinux != False and ansible_selinux.status == 'enabled'
      tags: selinux

Note: In my case, the posix collection was already in place, but if it isn’t it can be installed using ansible galaxy:

ansible-galaxy collection install ansible.posix

Testing the server

I used curl to test if I can reach the backend, and it worked as expected.

pm2tests $ curl https://node1/ -k
Hello world, this is my 0.1.1 try to js
pm2tests $ openssl s_client -connect node1:443 2>/dev/null|openssl x509 -noout -subject -issuer -dates
subject=C = ES, ST = Valencia, L = Valencia, O = Home lab Inc, OU = Automation, CN = node1
issuer=C = ES, ST = Valencia, L = Valencia, O = Home lab Inc, OU = Automation, CN = node1
notBefore=Apr 25 08:56:27 2021 GMT
notAfter=Apr 23 08:56:27 2031 GMT</pre>

And also the Subject Alternative Names were the ones which I expected.

X509v3 extensions:
    X509v3 Subject Alternative Name: 
        DNS:node1, IP Address:10.200.200.101, IP Address:192.168.33.201, IP Address:10.0.2.15

Conclusion

This was a proof of concept, but being able to generate self signed certificates will allow me to secure communications between frontend and some backend services which, by their nature, can’t be configured using another kind of certificate.

Using self signed certificates is something I don’t recommend, but it’s better than no encryption when you don’t have control on the network. Please use certificates issued by the organization’s CA when available.

The complete playbook

---
- name: install nginx with ssl
  hosts: node1
  gather_facts: yes
  become: yes
  become_user: root
  vars:
    private_key_path: /etc/pki/tls/private/mycert.key
    certificate_path: /etc/pki/tls/certs/mycert.crt
    csr_base_path: /etc/pki/tls/csr
    csr_path: "{{ csr_base_path }}/mycert.csr"

  tasks:
    - name: install cryptography module
      package:
        name: python3-cryptography
        state: installed
      tags: cert

    - name: create a directory for csr
      file:
        path: "{{ csr_base_path }}"
        state: directory
      tags: cert

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

    - name: generate a CSR
      community.crypto.openssl_csr:
        common_name: "{{ ansible_facts['fqdn'] }}"
        country_name: "ES"
        locality_name: "Valencia"
        state_or_province_name: "Valencia"
        organization_name: "Home lab Inc"
        organizational_unit_name: "Automation"
        privatekey_path: "{{ private_key_path }}"
        subject_alt_name: "{{ ['DNS:' +  ansible_facts['fqdn'] ] + ansible_facts['all_ipv4_addresses'] | map('regex_replace', '^', 'IP:') | list }}"
        path: "{{ csr_path }}"
        backup: yes
      tags: cert

    - name: generate a certificate
      community.crypto.x509_certificate:
        path: "{{ certificate_path }}"
        privatekey_path: "{{ private_key_path }}"
        csr_path: "{{ csr_path }}"
        provider: selfsigned
      tags: cert

    - name: install nginx
      package:
        name: nginx
        state: installed
      tags: nginx

    - name: setup redirect to https
      copy:
        content: 'return 301 https://$host$request_uri;'
        dest: /etc/nginx/default.d/redirect_https.conf
      tags: nginx

    - name: setup https server
      template:
        src: ./templates/https_server.conf.j2
        dest: /etc/nginx/conf.d/https_server.conf
      tags: nginx

    - name: enable nginx service
      service:
        name: nginx
        state: restarted
        enabled: yes
      tags: nginx

    - name: discover service facts
      service_facts:
      tags: firewall

    - name: open and https ports
      firewalld:
        service: "{{ item }}"
        state: enabled
      loop: [ http, https]
      when: ansible_facts['services']['firewalld.service']['state'] == "running"
      tags: firewall
     
    - name: install libsemanage-python
      package:
        name: python3-libsemanage
        state: installed
      when: ansible_selinux is defined and ansible_selinux != False and ansible_selinux.status == 'enabled'
      tags: selinux

    - name: enable SELinux network connect
      ansible.posix.seboolean:
        name: httpd_can_network_connect
        state: yes
        persistent: yes
      when: ansible_selinux is defined and ansible_selinux != False and ansible_selinux.status == 'enabled'
      tags: selinux