SaltStack, DNS and TLSA

Lately I blogged about how am I managing my DNS entries via SaltStack. So far it was about being a great time saver, but nothing that you couldn’t do manually with considerably more effort. This time, let’s take a look at something that would be in some setups almost impossible manually – adding TLSA records for your webs.

What is TLSA

TLSA records specify SSL certificate used by specified service. The records looks something like this: IN TLSA 3 1 1 1B66080B9C57281512A06A41314293F406687602A1C74642F388AD9984D8CAA9

All the details are specified in rfc7671, but let’s take a look at the basics. How is it typically constructed and what does it mean. Record name specifies port (in this case 443) and protocol (in this case tcp). Type is TLSA and the value contains some restrictions for the certificate. First number (in my case 3) specifies the type of the restriction. 3 means that restrictions apply to the certificate used by the service directly, lower numbers specify restrictions on CA or chain. Next number let’s you choose between taking into account full certificate (0) or just the public key (1). Using public key reduces the need to roll-over whenever our certificate is resigned. The last number specifies the hash type. You can choose between 1 for SHA-256 and 2 for SHA-512.

Now let’s talk about how SaltStack can help us fill in all that.

Modules and mines using custom functions

As with the SSHFP records, we would like to collect these records automatically from our minions. But there is no single command to get it. We can’t use as we did for SSHFP entries. But luckily, Salt_stack can be extended. Unfortunately for me, using Python. So let’s write a Python function, that gives us all the information we need to fill the DNS records. It can look something like the following one.

#!/usr/bin/env python3
import glob
import re
import os.path
import ssl
import hashlib
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

def _sha_digests(something):
    sha256 = hashlib.sha256()
    sha512 = hashlib.sha512()
    return sha256.hexdigest(), sha512.hexdigest()

def get_tlsa(cert):
    crt = False
    with open(cert, "rb") as fl:
        crt = load_pem_x509_certificate(, default_backend())
    if not crt:
        return False
    pk = crt.public_key().public_bytes(format=PublicFormat.SubjectPublicKeyInfo, encoding=Encoding.DER)
    sha256, sha512 = _sha_digests(pk)
    return [
        f'3 1 1 {sha256}',
        f'3 1 2 {sha512}'

Now that we have a function, what do we do with that? We need to somehow distribute it to our minions and make them use it to mine our data. To do that, we put it into Salt root directory in subdirectory _modules/tlsa_dumper into file This way, Salt with distribute it to all minions over the time. To speed things up we can call salt '*' saltutil.sync_modules. After that all the public functions – those not starting with underscore can be used for whatever we want. Syntax is {directory_name}.{function_name}. Simple call can look something like this

salt 'minion' tlsa_dumper.get_tlsa /etc/ssl/nginx/;

This will get us data for one of the certificates we have on one minion if we know where exactly it is. But that is not the end of the story. Lets protect visitors of all our web services via TLSA. Let’s expand our function a little bit more…​


def _get_webs(conf):
    ret = {}
    webs = []
    tlsa = []
    srv = re.compile(r'^\s*server_name\s+(.*);$')
    cert = re.compile(r'^\s*ssl_certificate\s+(.*);$')
    with open(conf) as file:
        for ln in file:
            m = cert.match(ln)
            if m:
                tlsa = _get_tlsa(
                if os.path.isfile( + '.new'):
                    tlsa = tlsa + get_tlsa( + '.new')
            m = srv.match(ln)
            if srv.match(ln):
                webs = webs +' ')
    for web in webs:
        ret[web] = tlsa
    return ret

def get_nginx_webs():
    ret = {}
    for conf in glob.glob("/etc/nginx/vhosts.d/*.conf"):
    return ret

Now it will go through all the Nginx virtual hosts, get their server names and corresponding TLSA records. It will also get records for keys ending with .new to handle roll-over. All that is left is integrate our function into SaltStack Mine like this:

    - mine_function: tlsa_dumper.get_nginx_webs

And to put the harvested data into use by putting them into our domain.

{%- set domain = ' %}
{%- for srv, webs in salt.saltutil.runner('mine.get', tgt='*', fun='webs', tgt_type='compound').items() %}
{%-     for web_full in webs %}
{%-         if web_full and web_full is match(".*" + domain + "$") %}
{%-             if web_full != grains['id'] %}
          - name: {{ web_full.replace('.' + domain,'') }}
            type: CNAME
            content: {{ srv }}.
{%-             endif %}
{%-             if webs.get(web_full, False) %}
{%-                 for tlsa in webs[web_full] %}
          - name: _443._tcp.{{ web_full.replace('.' + domain,'') }}
            type: TLSA
            content: {{ tlsa|yaml_dquote }}
{%-                 endfor %}
{%-             endif %}
{%-         endif %}

{%-     endfor %}
{%- endfor %}

As you can see, not only do we get all TLSA entries, but at the same time, we get all the CNAME records pointing to the right server for free. We can easily extend the setup to be able to collect TLSA records for smtp servers and other services as well. Would be possible to do it manually? Yes, with considerable effort and only if your ACME client supports keeping the same key. But with this setup, you can regenerate keys whenever you like and everything will fix itself.

Hope this series of blog post got you interested if not into SaltStack, then at least into putting some more data into your domains. DNS can be really useful when paired with DNSSEC and if you have a simple way how to feed all relevant data into it 😉 Let’s all get SSHFP, TLSA, DANE, SPF, DMARC, CAA and more and build a safer Internet with those!


Zanechte komentář

Všechny údaje jsou povinné. E-mail nebude zobrazen.