Managing an SSL-enabled PostgreSQL server using Ansible


In Fall 2023, I’ve bought a set of four Raspberry Pi 4 and made a cute little rack out of them:

A set of four Raspberry Pi 4 Model B (4 GB RAM). I’m using an Utronics RPi rack and PoE so that I use the least amount of cables as possible. The two “boxes” on top of the rack are a master Raspberry Pi (on the left) which serves as a PXE server and a PoE enabled network switch (on the right).

Recently, I’ve been immersed in playing around with geometry data, more specifically playing around with PostGIS.

I’ve been currently reading through an excellent publication PostGIS in Action. Together with the book, there many helper scripts which create little playground databases and pump data into them. Since my work laptop is the same machine as personal laptop, I really didn’t want to pollute my local PostgreSQL instance with such data.

I’ve also wanted to try out a certificate-based authentication which was mentioned by Stephen Frost on his PostgreSQL conference in 2023.

Since I already knew a little bit about Ansible as I had been setting up my RPi cluster using Ansible, I’ve decided to create a separate playbook which would automatically set up a SSL-enabled PostGIS server for me. Side note: since I’m booting my Raspberries in my rack using PXE, I also needed to configure the data directory which are stored on a separate SSD, managed and provided by the “master” Raspberry.

In fact, the only way you can connect to the PostgreSQL database server is via a SSL certificate, provided you’re not logged in to the server directly via SSH -which in my infrastructure is also possible only using certificates.

Since I don’t own neither DNS domain nor CA-signed certificate, I generated self-signed certificates for the purposes of my PoC.

First, we will setup the PostgreSQL server. Here is what my server.yml playbook looks like:

- name: Create certificate directories
  ansible.builtin.file:
    path: "{{ mount.dir }}/postgresql/{{ ser }}/{{ postgresql.version }}/main"
    state: directory
    mode: '0700'
  register: postgresql_certificate_dir

- name: Add postgres user to ssl-cert group
  user:
    name: postres
    groups: ssl-cert
    append: true

- name: Generate self-signed OpenSSL certificate/key
  command: "openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout {{ postgresql_certificate_dir.path }}/{{ tls.server.key_name }} -out {{ postgresql_certificate_dir.path }}/{{ tls.server.cert_name }} -subj '{{ tls.server.subj_params }}'"
  args:
    creates: "{{ postgresql_certificate_dir.path }}/{ tls.server.cert_name }}"

- name: Create CA root key
  ansible.builtin.copy:
    src: "{{ postgresql_certificate_dir.path }}/{{ tls.server.cert_name }}"
    dest: "{{ postgresql_certificate_dir.path }}/{{ tls.server.ca_root_name }}"
    mode: '0444'
    remote_src: true

- name: Set permissions on TLS files
  ansible.builtin.file:
    path: "{{ item }}"
    mode: "{{ usr_read_only }}"
  loop:
    - "{{ postgresql_certificate_dir.path }}/{{ tls.server.cert_name }}"
    - "{{ postgresql_certificate_dir.path }}/{{ tls.server.key_name }}"
  vars:
    usr_read_only: '0400'

- name: Uncomment SSL configuration in postgresql.conf
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ postgresql.version }}/main/postgresql.conf"
    regexp: '^#ssl\s*=\s*off'
    line: 'ssl = on'

- name: Enable SSL port listen
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ postgresql.version }}/main/postgresql.conf"
    regexp: '^#listen_addresses\s*=\s*'
    line: "listen_addresses = '0.0.0.0'"

- name: Uncomment ssl_ca_file configuration in postgresql.conf
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ postgresql.version }}/main/postgresql.conf"
    regexp: '^#ssl_ca_file\s*=\s*.*$'
    line: "ssl_ca_file = '{{ tls.server.ca_root_name}}'"

- name: Uncomment ssl_cert_file configuration in postgresql.conf
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ postgresql.version }}/main/postgresql.conf"
    regexp: '^#ssl_cert_file\s*=\s*.*$'
    line: "ssl_cert_file = '{{ tls.server.cert_name }}'"

- name: Uncomment ssl_cert_file configuration in postgresql.conf
  ansible.builtin.lineinfile:
    path: "/etc/postgresql/{{ postgresql.version }}/main/postgresql.conf"
    regexp: '^#ssl_key_file\s*=\s*.*$'
    line: "ssl_key_file = '{{ tls.server.key_name }}'"

- name: Configure PostgreSQL so that TLS is forced (and available for local network nodes only)
  ansible.builtin.template:
    src: "{{ role_path }}/files/pg_hba.conf.j2"
    dest: /etc/postgresql/{{ postgresql.version }}/main/pg_hba.conf
    mode: '0644'

- name: Chown the cert dir
  ansible.builtin.file:
    path: "{{ postgresql_certificate_dir.path }}"
    state: directory
    owner: postgres
    group: postgres
    recurse: true

- name: Restart PSQL
  ansible.builtin.systemd_service:
    daemon_reload: true
    name: postgresql
    enabled: true
    state: restarted

A few important notes here. The way we tell PostgreSQL how it should handle authentication is using its feature called host-based authentication, namely the pg_hba.conf file located directly at the PostgreSQL server. The pg_hba.conf.j2 I’m using is a fork of the “vanilla” pg_hba.conf and has the following contents:

# DO NOT DISABLE! 
# If you change this first entry you will need to make sure that the 
# database superuser can access the database using some other method. 
# Noninteractive access to all databases is required during automatic 
# maintenance (custom daily cronjobs, replication, and similar tasks). 
# 
# Database administrative login by Unix domain socket. 
# 
# This auth configuration is just a proof of concept of configuring TLS 
# using Ansible; the 'trust' mode for localhost connections should in no 
# way be neither used in a real environment, nor should it use self-signed certs! 
local all mzezulka trust local all postgres trust 
# Anyone outside must connect using SSL! 
hostssl all all {{ networking.all_local }} cert

First of the two authentication methods is what PostgreSQL calls trust and what this means in practice is that the user is automatically authorized without any authentication (and in general, this is not a recommended method for obvious reasons). The first three words local all mzezulka tell PostgreSQL to authorize user mzezulka to any database provided he is logged in directly on the PostgreSQL server (that’s the local part).

The second authentication is the more interesting one.