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:
- Generate a private key: community.crypto.openssl_privatekey
- Generate a Certificate Signing Request: community.crypto.openssl_csr
- 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:
- Redirect http traffic to https
- 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