Systemd Setup

Certhub ships with a host of systemd service, timer and path units which can be combined in various ways to satisfy different use cases.

Most of them are designed to run as an unprivileged system user. The systemd units default to certhub as the UID as well as the primary GID. Also the home directory by default is expected to be located at /var/lib/certhub.

Certhub User

Add the certhub user and group on the target system. Use the --shell /usr/bin/git-shell option in order to enable git repository replication.

$ sudo adduser --system --group --home /var/lib/certhub --shell /usr/bin/git-shell certhub

Same thing as an Ansible task:

- name: Certhub user present
  user:
    name: certhub
    state: present
    system: yes
    home: /var/lib/certhub
    shell: /usr/bin/git-shell

Directory Structure

Configuration for each certificate is expected in /etc/certhub. This directory must not be writable by the certhub user. Any files holding secrets such as API tokens or DNS TSIG keys should be owned by root with group certhub and permissions 0640 in order to prevent leaking information to other unprivileged processes.

$ sudo mkdir /etc/certhub
- name: Certhub config directory present
  file:
    path: /etc/certhub
    state: directory
    owner: root
    group: root
    mode: 0755

A status directory is used to trigger certificate renewal via systemd path units. The certhub user obviously needs write access to that directory. By default it is located at /var/lib/certhub/status.

$ sudo -u certhub mkdir /var/lib/certhub/status
- name: Certhub status directory present
  file:
    path: /var/lib/certhub/status
    state: directory
    owner: certhub
    group: certhub
    mode: 0755

Local Repository

By default systemd units expect the local certificate repository in /var/lib/certhub/certs.git. Also it is recommended to at least set user.name and user.email in the git configuration of the certhub user. Some git versions complain if push.default is not set. Thus it is best to specify that explicitly as well.

$ sudo -u certhub git config --global user.name Certhub
$ sudo -u certhub git config --global user.email certhub@$(hostname -f)
$ sudo -u certhub git config --global push.default simple
$ sudo -u certhub git init --bare /var/lib/certhub/certs.git
$ sudo -u certhub git gau-exec /var/lib/certhub/certs.git git commit --allow-empty -m'Init'
- name: Git configured
  become: yes
  become_user: certhub
  loop:
    - { name: "user.name", value: Certhub }
    - { name: "user.email", value: "certhub@{{ ansible_fqdn }}" }
    - { name: "push.default", value: simple}
  git_config:
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    scope: global

- name: Certhub repository present
  become: yes
  become_user: certhub
  command: >
    git init --bare /var/lib/certhub/certs.git
  arg:
    creates: /var/lib/certhub/certs.git

- name: Certhub repository initialized
  become: yes
  become_user: certhub
  command: >
    git gau-exec /home/certhub/certs.git
    git commit --allow-empty -m'Init'
  arg:
    creates: /var/lib/certhub/certs.git/refs/heads/master

ACME Client Setup

ACME clients need a way to store Let’s Encrypt account keys. By default systemd units expect home directories for the supported ACME clients to be inside /var/lib/certhub/private/. This directory must not be world-readable.

$ sudo -u certhub mkdir -m 0700 /var/lib/certhub/private
- name: Certhub private directory present
  file:
    path: /var/lib/certhub/private
    state: directory
    owner: certhub
    group: certhub
    mode: 0700

Certbot Setup

Certbot needs some special configuration in order to make it run as an unprivileged user. The following configuration directives need to be placed inside /var/lib/certhub/.config/letsencrypt/cli.ini.

Also the referenced directories should be created before running certbot for the first time.

Shell:

$ sudo -u certhub mkdir -p /var/lib/certhub/private/certbot/{work,config,logs}
$ sudo -u certhub mkdir -p /var/lib/certhub/.config/letsencrypt
$ sudo -u certhub tee /var/lib/certhub/.config/letsencrypt/cli.ini <<EOF
work-dir = /var/lib/certhub/private/certbot/work
config-dir = /var/lib/certhub/private/certbot/config
logs-dir = /var/lib/certhub/private/certbot/logs
EOF

Ansible:

- name: Certbot directory structure present
  loop:
    - /var/lib/certhub/.config/letsencrypt
    - /var/lib/certhub/private/certhub/work
    - /var/lib/certhub/private/certhub/config
    - /var/lib/certhub/private/certhub/log
  file:
    path: "{{ item }}"
    state: directory
    recursive: true
    owner: certhub
    group: certhub
    mode: 0755

- name: Certbot cli.ini present
  copy:
    dest: /var/lib/certhub/.config/letsencrypt/cli.ini
    owner: certhub
    group: certhub
    mode: 0755
    content: |
      work-dir = /var/lib/certhub/private/certbot/work
      config-dir = /var/lib/certhub/private/certbot/config
      logs-dir = /var/lib/certhub/private/certbot/logs

Lego Setup

Shell:

$ sudo -u certhub mkdir -p /var/lib/certhub/private/lego/{accounts,certificates}

Ansible:

- name: Lego directory structure present
  loop:
    - /var/lib/certhub/private/lego/accounts
    - /var/lib/certhub/private/lego/certificates
  file:
    path: "{{ item }}"
    state: directory
    recursive: true
    owner: certhub
    group: certhub
    mode: 0755

Domain Validation

Choose the challenge method which best suits the infrastructure. DNS-01 challenge is unavoidable for wildcard certificates. Currently DNS-01 is the only method which is supported out-of-the box by certhub and which is covered by integration tests.

Certhub ships with DNS-01 challenge hooks for nsupdate and Lexicon. The hooks need to be configured using an environment file normally located in /etc/certhub/%i.certhub-certbot-run.env. An example for certbot configuration is part of the integration test suite. See the manpages certhub-hook-lexicon-auth and certhub-hook-nsupdate-auth for more detailed information about the involved environment variables.

In the case of lego the challenge method is selected using command line arguments to the lego binary, authentication tokens are passed in via environment variables. All configuration is passed in via an environment file normally located in /etc/certhub/%i.certhub-lego-run.env. An example configuration is part of the integration test suite. See the manpage certhub-lego-run@.service for more detailed information about the involved environment variables.

Note that it is not recommended to specify secrets like API tokens in environment variables or command line flags. Regrettably most of today’s software authors seem to ignore this fact. An effective way to prevent secrets from leaking via process table is to keep them in files with tight access restrictions. Regrettably neither Lexicon nor lego do support this approach. Thus for production grade setups it is unavoidable to either use the nsupdate method or implement custom challenge hook scripts which are capable of reading API tokens from files.

Also note that HTTP-01 validation can be implemented quite easily if a reverse proxy serving the whole range of sites is already in place. In this case it is enough to proxy the path .well-known/acme-challenge to the certhub controller and then run a HTTP server and an ACME client in webroot-mode.

Refer to the following section for detailed directives on how to customize services via drop-ins.

Systemd Unit Customization

Certhub ships with systemd units which are capable of running one of the supported ACME clients in order to issue or renew a certificate and then store it in the certificate repository.

All the units are extensively configurable via systemd unit drop-ins. Units and drop-ins shipped with certhub are located in lib/systemd/system inside the installation prefix (usually /usr/local). Create corresponding drop-in directories inside /etc/systemd/system and then copy over selected drop-ins in order to customize certhub service, path and timer units.

Certificates

All systemd units are designed as templates. The instance name serves as the basename for configuration as well as generated certificates.

In order to avoid problems it is recommended to only use characters allowed in path components. I.e., alphanumeric plus URL-safe special characters such as the period and minus.

The following steps are required to configure a new certificate.

CSR

Generate a CSR from the TLS servers private key. When working with Ansible use delegation to run the openssl req command on another host than the certhub controller. Add the CSR to /etc/certhub/${DOMAIN}.csr.pem. In simple setups it is recommended to use the domain name as the config base name.

Shell:

$ export SERVER=tls-server.example.com
$ export DOMAIN=tls-server.example.com
$ ssh "${SERVER}" sudo openssl req -new \
    -key "/etc/ssl/private/${DOMAIN}.key.pem" \
    -subj "/CN=${DOMAIN}" \
    | sudo tee "/etc/certhub/${DOMAIN}.csr.pem"

Ansible:

- name: CSR generated
  delegate_to: "{{ SERVER }}"
  changed_when: false
  register: csr_generated
  command: >
    openssl req -new
    -key "/etc/ssl/private/{{ DOMAIN }}.key.pem"
    -subj "/CN={{ DOMAIN }}"

- name: CSR configured
  register: csr_configured
  copy:
    dest: "/etc/certhub/{{ DOMAIN }}.csr.pem"
    content: "{{ csr_generated.stdout }}
    owner: root
    group: root
    mode: 0644

ACME Client Configuration

Add additional configuration for the ACME client to one of the following files: /etc/certhub/${DOMAIN}.certbot.ini or /etc/certhub/${DOMAIN}.certhub-lego-run.env. Working examples for testing purposes are part of certhub integration tests

Initial Certificate

Run certhub-${ACME_CLIENT}-run@${DOMAIN}.service once in order to obtain the first certificate and add it to the repository.

Example for ACME_CLIENT=certbot and DOMAIN=tls-server.example.com

$ export ACME_CLIENT=certbot
$ export DOMAIN=tls-server.example.com
$ sudo systemctl start "certhub-${ACME_CLIENT}-run@${DOMAIN}.service"

Ansible:

- name: Certificate issued
  systemd:
    name: "certhub-{{ ACME_CLIENT }}-run@{{ DOMAIN }}.service"
    state: started

Configure Certificate Renewal

Enable and start timer and path units.

Shell:

$ export DOMAIN=tls-server.example.com
$ sudo systemctl enable --now "certhub-cert-expiry@${DOMAIN}.timer"
$ sudo systemctl enable --now "certhub-certbot-run@${DOMAIN}.path"

Ansible:

- name: Path and timer units enabled and started
  loop:
    - "certhub-cert-expiry@{{ DOMAIN }}.timer"
    - "certhub-certbot-run@{{ DOMAIN }}.path"
  systemd:
    name: "{{ item }}"
    enabled: true
    state: started

Certificate Distribution

In order to propagate certificates to tls servers it is recommended to mirror the repository from the certhub controller to the respective machines. The certhub-repo-push@.service unit can be used to propagate these changes to another host, certhub-repo-push@.path unit to trigger it automatically whenever the master branch of the repository changes.

Note, certhub-repo-push@.service requires working SSH access via public key authentication to the remote end.

This unit takes the full remote URL including the path as the service instance name which needs to be escaped using systemd-escape --template. Note, when copy-pasting output from system-escape into a shell then it is necessary to escape backslashes with an additional backslash.

Shell:

$ export REMOTE="tls-server.example.com:/var/lib/certhub/certs.git"
$ export PATH_UNIT="$(systemd-escape --template certhub-repo-push@.path ${REMOTE})"
$ export SERVICE_UNIT="$(systemd-escape --template certhub-repo-push@.service ${REMOTE})"
$ sudo systemctl enable --now "${PATH_UNIT}"
$ sudo systemctl start "${SERVICE_UNIT}"

Ansible:

tasks:
  - name: Certificate distribution activated
    notify: Certificate distribution run
    vars:
      UNIT: "{{ lookup('pipe','systemd-escape --template certhub-repo-push@.path ' + REMOTE|quote) }}"
    systemd:
      name: "{{ UNIT }}"
      enabled: true
      state: started

handlers:
  - name: Certificate distribution run
    vars:
      UNIT: "{{ lookup('pipe','systemd-escape --template certhub-repo-push@.service ' + REMOTE|quote) }}"
    systemd:
      name: "{{ UNIT }}"
      state: started

Certificate export and service reload

Whenever a new commit is pushed to the local repository on a tls server node, selected certificates may be exported such that they can be used in the config of tls servers. Also affected tls services should be reloaded wenever an exported certificate was renewed. Enable and start certhub-cert-export@.path and certhub-cert-reload@.path in order to automate this process on tls server nodes. Both of these units take a certificate configuration basename as their instance name.

All units which should be reloaded whenever the exported certificate changes should be listed in /etc/certhub/${DOMAIN}.services-reload.txt.

The default destination for exported certificates is /var/lib/certhub/certs.

Shell:

$ export DOMAIN=tls-server.example.com
$ sudo -u certhub mkdir /var/lib/certhub/certs
$ sudo tee "/etc/certhub/${DOMAIN}.services-reload.txt" <<EOF
nginx.service
EOF
$ sudo systemctl enable --now "certhub-cert-export@${DOMAIN}.path"
$ sudo systemctl enable --now "certhub-cert-reload@${DOMAIN}.path"
$ sudo systemctl start "certhub-cert-export@${DOMAIN}.service"

Ansible:

tasks:
  - name: Certhub certificate directory exists
    file:
      path: /var/lib/certhub/certs
      state: directory
      owner: certhub
      group: certhub
      mode: 0755

  - name: Service reload configuration
    copy:
      dest: "/etc/certhub/{{ DOMAIN }}.services-reload.txt"
      owner: root
      group: root
      mode: 0644
      content: |
        nginx.service

  - name: Certificate export and service reload path units enabled and started
    notify: Certificate exported
    loop:
      - "certhub-cert-export@{{ DOMAIN }}.path"
      - "certhub-cert-reload@{{ DOMAIN }}.path"
    systemd:
      name: "{{ item }}"
      enabled: true
      state: started

handlers:
  - name: Certificate exported
    systemd:
      name: "certhub-cert-export@{{ DOMAIN }}.service"
      state: started

Sending certificates

Similar to the export/reload scenario described above, it is also possible to send exported certificates to another destination/process. Enable and start certhub-cert-export@.path and certhub-cert-send@.path in order to automate this process. Both of these units take a certificate configuration basename as their instance name.

List all destinations the certificate should be sent to in /etc/certhub/${DOMAIN}.destinations-send.txt. By default the certificate will be sent using the mail command. This can be changed using the CERTHUB_CERT_SEND_COMMAND. A good place to specify the variable is, e.g., /etc/certhub/%i.certhub-cert-send.env.

Note that the certificate is written to stdin of the specified command. Hence it is quite easy to send it to remote scripts using ssh.

Shell:

$ export DOMAIN=tls-server.example.com
$ sudo -u certhub mkdir /var/lib/certhub/certs
$ sudo tee "/etc/certhub/${DOMAIN}.destinations-send.txt" <<EOF
audit@example.com
EOF
$ sudo systemctl enable --now "certhub-cert-export@${DOMAIN}.path"
$ sudo systemctl enable --now "certhub-cert-send@${DOMAIN}.path"
$ sudo systemctl start "certhub-cert-export@${DOMAIN}.service"

Ansible:

tasks:
  - name: Certhub certificate directory exists
    file:
      path: /var/lib/certhub/certs
      state: directory
      owner: certhub
      group: certhub
      mode: 0755

  - name: Certificate send configuration
    copy:
      dest: "/etc/certhub/{{ DOMAIN }}.destinations-send.txt"
      owner: root
      group: root
      mode: 0644
      content: |
        audit@example.com

  - name: Certificate export and send path units enabled and started
    notify: Certificate exported
    loop:
      - "certhub-cert-export@{{ DOMAIN }}.path"
      - "certhub-cert-send@{{ DOMAIN }}.path"
    systemd:
      name: "{{ item }}"
      enabled: true
      state: started

handlers:
  - name: Certificate exported
    systemd:
      name: "certhub-cert-export@{{ DOMAIN }}.service"
      state: started