In diesem Beitrag beschreibe ich, wie ich die Kommunikation vieler Dienste untereinander und nach außen mit SSL-Zertifikaten abzusichern plane. Die Zertifikate sollen von Let’s Encrypt stammen und durch Puppet auf die Hosts verteilt werden. Die Verifikation der Domain wird dabei nicht über http gemacht, man muss also keine Ports umleiten oder Webserver installieren. Wir verwenden dazu eine DNS-Challenge.

Bisherige Beiträge in der Reihe:

Voraussetzungen

Bisher waren die meisten Beiträge in der Reihe relativ unabhängig von Voraussetzungen. Mit steigender Komplexität erwachsen jetzt aber einige Dinge, die gegeben sein sollten:

  • Funktionierendes Setup von Puppet Server und Puppet DB.
  • Existierende Domain - ich habe mir für meine interne Infrastruktur eine Domain genommen. Die muss gültig sein und nicht frei erfunden, damit man dafür valide SSL-Zertifikate von Let’s Encrypt bekommt.
  • Korrekt verteilte FQDNs auf den Hosts. Jede VM sollte also über eine URL wie puppet.domain.tld erreichbar sein.
  • Möglichkeit, DNS-Einträge über eine API vorzunehmen. Bei mir (HostEurope) geht das nicht, also wird ein Workaround beschrieben.

Kurze Klarstellung noch: Was an DNS-Einträgen global im Internet gepflegt wird und was intern in der Firewall gepflegt wird, muss dabei nicht das gleiche sein. Die Hosts müssen also nicht von außen erreichbar sein - nicht einmal ihre DNS-Einträge müssen sich von außen auflösen. Die Verifikation der DNS-Einträge läuft über andere Records.

DNS braucht eine API

Um die DNS-Verifikation automatisiert ablaufen lassen zu können, müssen Änderungen am DNS der verwendeten Domain über eine API irgendeiner Art möglich sein. Bei meiner Domain, die ich bei HostEurope registriert habe, ist das nicht möglich. Ich tausche daher die Nameserver meiner Domain gegen die von CloudFlare aus.

Einrichtung bei CloudFlare

Der Betrieb einer Domain bei CloudFlare bietet verschiedene Features, darunter auch Dinge wie Ausfallsicherheit und DDoS-Schutz. Ich brauche allerdings nur die DNS-API davon. In seiner Grundfassung ist das Angebot von CloudFlare obendrein auch noch kostenfrei, was die Sache sehr viel einfacher aufzusetzen macht.

Wie genau man die Domain bei CloudFlare anlegt und die NameServer-Einträge auf die von CloudFlare umleitet, kann an dieser Stelle nicht beschrieben werden; die Methode unterscheidet sich bei jedem Anbieter. Wirklich schwierig ist es jedoch nicht: Man nimmt in den Einstellungen der Domain einfach die Änderung vor, dass die Nameserver von CloudFlare, die man aus der Doku dort erhält, verwendet werden. Zeigt einem CloudFlare im Account dann an, dass alles in Ordnung ist, kann es weiter gehen.

Verwendete Software

Wir verwenden einige neue Softwarekomponenten für das Setup:

  • dehydrated als Client für Let’s Encrypt
  • Das Puppet-Modul bzed-letsencrypt in Form eines Forks von mir
  • Als Hook für dehydrated ein Python-Script, was bei CloudFlare die DNS-Verifikation durchführt

Die muss man jetzt nicht alle herunterladen, das machen wir im Laufe des Artikels dann.

Noch zum Fork: Das verwendete Puppet-Modul hatte einige Schwierigkeiten mit einem Update von dehydrated und erlaubte keine Angabe zusätzlicher Einstellungen - in meinem Fall keine Übergabe von Zugangsdaten für CloudFlare. Ich habe das Modul also geforked und die notwendigen Änderungen vorgenommen. Die habe ich als pull requests auch wieder upstream geschickt, bisher wurde es aber noch nicht upstream aufgenommen.

Implementierung

Wir erstellen hier ein Setup, das sowohl auf den einzelnen Hosts die Einbindung des Puppet-Moduls erfordert als auch auf dem Puppet Server. Die einzelnen Hosts erstellen ihre privaten Schlüssel und CSRs (Zertifikatsanfragen) lokal und exportieren letztere als exported resources an den Puppet Server (deswegen brauchen wir auch unbedingt die Puppet DB!). Dieser kümmert sich um die Verifikation der angefragten Domains und die Beantragung der Zertifikate bei Let’s Encrypt. Als jeweilige Domain wird einfach der FQDN des einzelnen Hosts genommen. Das Setup taugt also für Infrastruktur-Server und -Clients

  • möchte man etwa noch eine Menge vHosts auf einem Webserver betreiben o.Ä., muss man das von diesem Setup getrennt machen.

Auf dem Puppet Server

Ich gehe hier davon aus, dass der Puppet Server den FQDN puppet.domain.tld hat und in der hiera.yaml eine Direktive enthalten ist, anhand derer nodes/%{trusted.certname}.yaml eingebunden wird.

Installieren der Voraussetzungen

Wer r10k o.Ä. verwendet, kann hier natürlich auch eine Puppetfile schrieben, wir machen es in der Kommandozeile. Auf dem Puppet Server geben wir ein:

sudo /opt/puppetlabs/bin/puppet module install camptocamp-openssl
sudo /opt/puppetlabs/bin/puppet module install puppetlabs-stdlib
sudo /opt/puppetlabs/bin/puppet module install puppetlabs-concat
sudo /opt/puppetlabs/bin/puppet module install puppetlabs-vcsrepo
sudo git clone https://github.com/very-emmazing/bzed-letsencrypt /etc/puppetlabs/code/modules/bzed-letsencrypt

Konfiguration des Puppet Servers

Wer schon ein Modul profiles pflegt (um das Roles/Profiles Pattern umzusetzen), kann einfach ein neues Manifest anlegen, ansonsten legen wir jetzt ein neues Modul profiles an. Ich gebe die Befehle an, um das ganze live umzusetzen, was natürlich eine total dumme Idee ist. Weil ich aber nicht weiß, wie genau Ihr Euren Puppet-Code auf den Puppet Server bringt (hoffentlich verwendet Ihr irgendwo git), müsst Ihr diese allgemeinen Anweisungen halt auf Euer eigenes Setup übersetzen.

sudo mkdir -p /etc/puppetlabs/code/modules/profiles/{files,manifests,data}

In die Datei hiera.yaml des Moduls schreiben wir nur was kurzes:

# Datei /etc/puppetlabs/code/modules/profiles/hiera.yaml
---
version: 5
defaults:
  datadir: data
  data_hash: yaml_data

hierarchy:
  - name: Common
    path: 'common.yaml

Dort können wir Standardeinstellungen für unser neues Modul hinterlegen. Das ist insofern sinnvoll, als dass wir erst auf die production-API von Let’s Encrypt umsteigen wollen, wenn wir alles auf staging getestet haben.

Nun schreiben wir das Profil sslmaster in Puppet-Code:

# Datei /etc/puppetlabs/puppet/code/modules/profiles/manifests/sslmaster.pp

# Set up cert management on puppet master
class profiles::sslmaster {

  $letsencrypt_ca   = lookup('profiles::sslmaster::ca')
  $cloudflare_acc   = lookup('profiles::sslmaster::cloudflare_acc')
  $cloudflare_token = lookup('profiles::sslmaster::cloudflare_token')

  $python_packages = [
    ['cffi'            , '1.5.0'],
    ['cryptography'    , '1.2.3'],
    ['dnspython'       , '1.15.0'],
    ['enum34'          , '1.1.2'],
    ['future'          , '0.15.2'],
    ['idna'            , '2.0'] ,
    ['ipaddress'       , '1.0.16'],
    ['ndg-httpsclient' , '0.4.0'],
    ['pyasn1'          , '0.1.9'],
    ['pyOpenSSL'       , '17.3.0'],
    ['requests'        , '2.9.1'],
    ['pycparser'       , '2.14'],
    ['six'             , '1.10.0'],
    ['tld'             , '0.7.6'],
    ['urllib3'         , '1.22'],
    ['wheel'           , '0.28.0'],
  ]

  $python_packages.each |$package| {
    package { $package[0]:
      ensure  => $package[1],
      name    => $package[0],
      provider=> 'pip',
    }
  }

  class { 'letsencrypt':
    letsencrypt_ca  = $letsencrypt_ca,
    hook_source     = 'puppet:///modules/profiles/dnshook.py',
    hook_env        = ['CF_EMAIL=${cloudflare_acc}', 'CF_TOKEN=${cloudflare_token}'],
    manage_packages = false, # auf true stellen, falls sonst nirgendwo git installiert wird!
  }
}

Was passiert da? Der Reihe nach:

  1. Wir erstellen die neue Klasse profiles::sslmaster, um nachher dem Puppet Server dieses Profil zuweisen zu können.
  2. Wir laden die Einstellungen aus Hiera. Sind noch keine Einstellungen hinterlegt, holt es sich die Standardeinstellungen aus profiles/data/common.yaml.
  3. Wir definieren die Liste von Python-Paketen, die wir als Abhängigkeiten der DNS-Validierung installieren müssen und installieren diese über pip.
  4. Wir instanzieren die Klasse letsencrypt von upstream und nehmen etwas Konfiguration vor. Wir wählen die CA aus (derzeit noch auf staging), stellen ein, welches Script die Validierung vornehmen soll, übergeben zusätzliche Umgebungsvariablen mit den Zugangsdaten für CloudFlare und deaktivieren, dass das Modul das Paket git installiert, falls wir das schon irgendwo anders installieren. Ansonsten kann man das auf true stellen.

Wir brauchen noch die Standardeinstellungen in der common.yaml:

# Datei /etc/puppetlabs/code/modules/profiles/data/common.yaml
profiles::sslmaster::ca: 'staging'

Zugangsdaten für CloudFlare hinterlegen wir keine, das wäre auch dämlich. Das Vorhaben soll ruhig auseinanderfliegen, wenn gar keine angegeben sind. Die Fehlermeldung wird offensichtlich sein.

Als letztes fehlt der dns hook. Das Script gibt es zum Download auf github mit dem Dateinamen hook.py. Ich habe es umbenannt in dnshook.py (wer weiß, was in profiles/files/ noch alles für hooks auftauchen werden) und nach /etc/puppetlabs/code/modules/profiles/files/dnshook.py gespeichert.

Mit dem Hinterlegen der Zugangsdaten zu CloudFlare in der Konfiguration des Setups (und nicht des Moduls) sind wir mit dem Puppet Server fertig. Dazu verwenden wir wieder Hiera:

# Datei /etc/puppetlabs/code/environments/production/nodes/puppet.domain.tld

profiles::sslmaster::cloudflare_acc: '[email protected]'
profiles::sslmaster::cloudflare_token: '123456789'

Die richtigen Zugangsdaten sind natürlich einzufügen.

Konfiguration eines Hosts

Wie genau sich die Hosts Zertifikate beantragen sollen, muss auch konfiguriert werden. Dazu legen wir das Profil ssl an:

# Datei /etc/puppetlabs/code/modules/profiles/manifests/ssl.pp

# request DNS cert from puppet master
class profiles::ssl {
  $domains = lookup('profiles::ssl::domains', Array, 'first', [$facts["fqdn"],])

  $python_packages = [
    ['dnspython' , '1.15.0'],
    ['future'    , '0.15.2'],
    ['requests'  , '2.9.1'],
    ['six'       , '1.10.0'],
    ['tld'       , '0.7.6'],
  ]

  # dependencies for CloudFlare DNS hook
  $python_packages.each |$package| {
    package { $package[0]:
      ensure   => $package[1],
      name     => $package[0],
      provider => 'pip',
    }
  }

  class { 'letsencrypt':
    domains         => $domains,
    manage_packages => false,  # oder true, falls sonst git noch nicht installiert
  }
}

Was passiert hier? Der Reihe Nach:

  1. Wir sehen nach, ob in Hiera unter profiles::ssl::domains ein Array mit Domains hinterlegt ist, für die ein Zertifikat gebraucht wird. Das anzugeben macht natürlich nur in Host-spezifischen Hiera-Dateien Sinn. Sollte dem nicht so sein, wird automatisch der FQDN genommen.
  2. Es werden auch hier Abhängigkeiten installiert
  3. Die Klasse letsencrypt diesmal in der Konfiguration für einzelne Hosts. Wer sonst noch nirgends git installieren lässt, kann manage_packages => true sagen.

Zertifikat beantragen

Nach langer Vorarbeit können wir nun die Früchte unserer Arbeit ernten und uns über viel Automatisierung freuen. Haben wir nun einen neuen Host, beispielsweise ssltest.domain.tld, weisen wir ihm im Hiera die Klasse ssl zu und er macht sich automatisch ein Zertifikat und einen privaten Schlüssel:

# Datei /etc/puppetlabs/code/environments/production/nodes/ssltest.domain.tld

classes:
  - ssl

Das war’s schon. Jeder weitere einzelne Host wird auf diese Art konfiguriert. Man findet das Ergebnis in /etc/letsencrypt. Bis das Zertifikat dort ankommt, kann ein wenig Zeit vergehen, da der Puppet Agent bei seinem nächsten Lauf erst die Ressource der Zertifikatsanfrage exportiert, diese in der Puppet DB hinterlegt, der Puppet Server das irgendwann dort findet, das Zertifikat erstellt und das Resultat beim übernächsten Lauf des Puppet Agent auf dem einzelnen Host dort hin verteilt. Ist man besonders ungeduldig, kann man sich mit puppet agent -t behelfen.

Die Konfiguration der jeweiligen Dienste, welche dann die Zertifikate verwenden sollen, muss natürlich noch angepasst werden; als Pfade für private Schlüssel und Zertifikate muss auf die Dateien in /etc/letsencrypt gezielt werden.

Production

Klappt das alles und können die ersten Server sich mit einem Zertifikate der Fake CA von Let’s Encrypt ausweisen, kann man endlich auf die production CA umstellen. Dazu genügt ein Eintrag in der Konfiguration:

# Datei
/etc/puppetlabs/code/environments/production/nodes/sslmaster.domain.tld.yaml

# [...]
profiles::sslmaster::ca: 'production

Damit Überschreiben wir die Standardeinstellung des Profils und schalten die ganze Sache scharf.

Aktualisierungen?

Die Zertifikate werden regelmäßig auf ihre Gültigkeit geprüft - wenn der Puppet Agent läuft. Erneuerte Zertifikate werden auch automatisch beantragt. Meist ist jedoch dann noch zumindest ein reload oder gar restart des jeweiligen Dienstes notwendig, um das erneuerte Zertifikat zu verwenden. Das muss getrennt und je nach Dienst spezifisch noch implementiert werden - entweder ganz stupide mit cron job einfach irgendwann nachts die Dienste neu starten lassen oder in Puppet Abhängigkeiten zwischen der Eneuerung des Zertifikats und des Neustarts des Dienstes Definieren.