Let’s Encrypt und Puppet

lets-encrypt-logoIch habe früher fast alle meine Zertifikate von CAcert bezogen. Ich habe sie regelmäßig aktualisiert und für das deployen habe ich ein Puppet Modul, dass die Zertifikate verteilt.

Nach und nach bin ich auf Let’s Encrypt Zertifikate umgestiegen. Da ein hoher Grad an Automatisierung bei Let’s Encrypt immer wieder propagiert wird dachte ich mir, dass sollte auch mit Puppet dann kein zu großes Problem sein. Hier die Lösung, die bei mir nun in Betrieb ist:

Als erstes habe ich das Puppet Modul bzed/letsencrypt installiert. Das Modul benötigt eine PuppetDB. Über exported resources  transportiert es die CSRs auf den puppetmaster, macht dort die gesamte Abwicklung und transferiert dann die Ergebnisse wieder zurück auf den Node. Das funktioniert auch sehr gut.

Auf dem Puppetmaster habe ich die Klasse eingebunden und einige wenige Einstellungen gesetzt (hiera). Bei dem Hook handelt sich m übrigen um einen letsencrypt.sh Hook:

---
classes:
  - letsencrypt

letsencrypt::challengetype: 'http-01'
letsencrypt::hook_source: 'puppet:///modules/helper/%{::fqdn}/le_hook.sh'

Auf den Nodes habe ich die Klasse eingebunden und die Domains definiert für die ein Zertifikat bezogen werden soll:

---
classes:
- letsencrypt

letsencrypt::domains:
- 'foo.example.net'
- 'bar.example.net'
- 'baz.example.net'

PL_logo_vertical_RGB_lgIn meinem recht simplen Szenario ist es so, dass ich einen Gate-Server habe, hinter dem sich alle anderen Maschinen „verstecken“. Dort läuft ein Apache Server als Proxy, der Anfragen über HTTP nach hinten weiterreicht. Die Konfiguration von Apache erfolgt ebenfalls über Puppet. Eine Authentifizierung über DNS für ACME klappt bei mir leider nicht, weswegen ich HTTP nehmen muss. Das habe ich so gelöst, dass ich für die Apache vhosts einfach über ProxyPassMatch den .well-known/acme-challenge/ auf einen Apache Vhost auf dem Puppetmaster weiterreiche. Auf dem Gate-Server sieht das für einen Vhost wie folgt aus:

apache::vhost:
  'proxy-foo.example.net':
    servername: 'foo.example.net'
    serveradmin: 'webmaster@example.net'
    port: '80'
    docroot: '/var/www/empty'
    proxy_dest: 'http://10.20.30.1'
    proxy_pass_match:
      -
        path: '^/.well-known/acme-challenge/(.*)$'
        url: 'http://10.20.30.2/$1'
        params:
          retry: '0'
    headers:
      - 'unset X-Powered-By'
    proxy_preserve_host: true

Auf dem Puppetmaster wiederum läuft ein Vhost, der einfach alle Domains als ServerAlias eingetragen hat.

Nun kommt noch der Hook für letsencrypt.sh ins Spiel den ich oben bereits erwähnt habe. Das Skript schreibt die ACME-Challenge in eine Textdatei in den DocRoot und löscht sie nach dem Erfolg wieder:

#!/bin/bash
 
#
# http-01 hook
#
 
CHALLENGEDIR="/var/www/example.net/letsencrypt"
 
done="no"
if [[ "$1" = "deploy_challenge" ]]; then
    echo "${4}" > "${CHALLENGEDIR}/${3}"
    chmod 644 "${CHALLENGEDIR}/${3}"
    done="yes"
fi
 
if [[ "$1" = "clean_challenge" ]]; then
    rm "${CHALLENGEDIR}/${3}"
    done="yes"
fi
 
if [[ "${1}" = "deploy_cert" ]]; then
    # do nothing for now
    done="yes"
fi
 
if [[ ! "${done}" = "yes" ]]; then
    echo Unkown hook "${1}"
    exit 1
fi
 
exit 0

Voila! Es braucht ein paar Durchläufe bis alles über die Puppetdb jeweils transportiert wurde, aber alles läuft vollautomatisch ab. Sehr cool!

Wartungsseite für alle Apache vhosts eines Webservers realisieren

Über eine Lösung um einen einzelnen Apache Vhost mit einer Wartungsseite auszustatten habe ich bereits vor einigen Jahren hier geschrieben.

Jetzt stand ich vor der Aufgabe wegen Wartungsarbeiten auf meinem Webserver eine Wartungsseite für alle dort gehosteten Apache Vhosts zu schalten. Davor sitzt ein Gate-Server mit Apache und mod_proxy. Eine Lösung das zu realisieren wäre auf dem Gate-Server mit mod_rewrite nach dem Vorhandensein einer Maintenance-Datei zu schauen und wenn die Bedingung zutrifft alle Anfragen auf die Wartungsseite weiterzuleiten. Eine solche Lösung ist in Ansätzen unter anderem hier skizziert. Unschön finde ich dabei die unnötig vielen Prüfungen nach der Maintenance-Datei.

Ich habe nun eine andere Lösung realisiert, die in meinen Augen deutlich einfacher und eleganter ist. Auf dem Gate-Server lasse ich einen Apache-Vhost auf einem gesonderten Port laufen auf dem die Wartungsseite angezeigt wird. Für den Fall, dass ich die Wartungsseite vorschalten möchte, leite ich einfach allen HTTP und HTTPS Traffic auf die IP des Gate-Servers und den gesonderten Port um. Mit nur einer iptables Regel ist somit für alle vhosts auf dem Webserver eine Wartungsseite vorgeschaltet:

iptables -t nat -A OUTPUT -p tcp -d INTERNALWEBSERVERIP --match multiport --dports 80,443 -j DNAT --to-destination EXTERNALGATESERVERIP:PORT

Bei dem Apache Vhost auf dem Gateserver ist noch zu beachten, dass man nach Möglichkeit den aufrufenden Browsern / Bots etc. mitteilt, dass es sich um etwas temporäres handelt. Aus diesem Grund habe ich in dem Vhost zwei Dinge beachtet:

  1. Es wird ein HTTP Status Code 503 (Temporarily Unavailable) gesendet
  2. Es wird der HTTP Header Retry-After gesetzt

Im vhost sieht das wie folgt aus. Spannend sind die letzten 3 Zeilen. Meine Wartungsseite heißt übrigens auch 503.html

<VirtualHost *:PORT>
  ServerName maintenance.example.net
  ServerAdmin webmaster@example.net
 
  DocumentRoot "/var/www/maintenance"
 
  <Directory "/var/www/maintenance">
    Options -Indexes
    AllowOverride None
    Require all granted
  </Directory>
 
 
  ErrorLog "/var/log/apache2/maintenance.example.net_error.log"
  CustomLog "/var/log/apache2/maintenance.example.net_access.log" combined 
 
  RedirectMatch 503  ^/(?!503.html)  
  ErrorDocument 503 /503.html
  Header always set Retry-After "18000"
</VirtualHost>

HTTP Public Key Pinning / HPKP -> Erklärung und Einrichtung

Ein Problem „by-Design“ ist, dass jede CA für jeden Webserver Zertifikate ausstellen kann. Kaufe ich mir bei der CA foo für die Domain www.example.org ein Zertifikat, bin ich nicht davor geschützt, dass die CA bar kein Zertifikat für die gleiche Domain ausstellt. Alles schon vorgekommen… Da Webbrowser von Haus aus hunderten Zertifizierungsstellen vertrauen ist das auch kein Problem theoretischer Natur mehr, und mit Superfish oder Privdog sogar brandaktuell.

Es gibt keine 100%tige Lösung für das Problem mit den CAs, aber eine, die einen deutlichen Gewinn an Sicherheit bringt, und das für jede HTTPS Verbindung. Das ganze heißt HTTP Public Key Pinning und ist einfach erklärt: Der Webserver sendet bei seiner Antwort in einem HTTP Header mindestens zwei Informationen: a) Pins von zwei Schlüsseln und b) die Information wie lange diese gültig sein sollen. Der Webbrowser merkt sich diese Informationen und verweigert die Kontaktaufname, wenn die Pin des gesendeten Zertifikates nicht mit einer Pin aus dem HTTP Header übereinstimmt. Eine Pin ist übrigens der base64 encodete SHA256-Hash Fingerprint eines public Keys eines Zertifikats [Update: Danke Puhh für die Korrektur].
Die empfohlene Gültigkeit der Informationen liegt bei 60 Tagen, Das erklärt auch warum es zwei Pins sein müssen. Verliert man nämlich einen Key, oder dieser wird wegen einer Sicherheitslücke unsicher (Gruß an Heartbleed), schließt man potentielle Leser im schlimmsten Fall 60 Tage lang von seiner Webseite aus. Um hier das Risiko zu verringern müssen immer zwei Pins angegeben werden wovon eine die eines Backup Keys ist.

In der Praxis sieht das wie folgt aus:

  • Zwei Keys generieren:
    openssl genrsa -out www.example.org.hpkp1.key 4096
    openssl genrsa -out www.example.org.hpkp2.key 4096
  • Mit dem ersten Key einen CSR erstellen:
    openssl req -new -sha256 -key www.example.org.hpkp1.key -out www.example.org.csr
  • Zertifikat mit dem CSR besorgen
  • Zertifikat im Apache vhost einbinden
  • HPKP Header generieren. Dafür kann zum Beispiel das Skript hpkp-gen genommen werden.
  • HPKP Header in Apache einrichten. Dafür muss zuerst das Modul Headers aktiviert sein:
    a2enmod headers

    und danach der generierte HPKP Header in den vhost eingetragen werden:

      ## Header rules
      ## as per http://httpd.apache.org/docs/2.2/mod/mod_headers.html#header
      Header always set Public-Key-Pins: 'max-age=5184000; pin-sha256="+sCGKoPvhK0bw4OcPAnWL7QYsM5wMe/mn1t8VYqY9mM="; pin-sha256="bumevWtKeyHRNs7ZXbyqVVVcbifEL8iDjAzPyQ60tBE="'
  • Monitoring anpassen. Dieses sollte meckern sobald das Zertifikat in weniger als 60 Tagen abläuft. Dann sollte man mit dem Backup Key einen neuen CSR erzeugen, ein neues Zertifikat hinterlegen, einen neuen Backup-Key erstellen und auch den HPKP Header in Apache anpassen und die Pins entsprechend durchrotieren.
  • SSLLabs Test machen. Dort sollte ganz unten im letzten Kasten und darin im vorletzten Abschnitt „Public Key Pinning (HPKP)“ in grün und mit yes stehen. HSTS+HPKP

HPKP bringt darüber hinaus noch etwas spannendes mit. Wenn der Webbrowser eine Verbindung ablehnt, weil der Pin des vom Webserver gesendeten Zertifikates nicht mit einer im HTTP Header angegebenen oder im Webbrowser gespeicherten Pin übereinstimmt, kann dieser eine bestimmte Stelle informieren. Diese Stelle kann im HPKP Header optional mit angegeben werden. Dafür wird dort noch eine report-uri=“http://www.example.org/hpkpReportUrl“ hinzugefügt. Der Header sieht dann zum Beispiel so aus:

Public-Key-Pins: 'max-age=5184000; pin-sha256="+sCGKoPvhK0bw4OcPAnWL7QYsM5wMe/mn1t8VYqY9mM="; pin-sha256="bumevWtKeyHRNs7ZXbyqVVVcbifEL8iDjAzPyQ60tBE="; report-uri="http://www.pregos.info/hpkp.php"'

Die Info wird im JSON Format übertragen und als POST gesendet.

Update
Hier das hpkp.php Skript:

<?php
 
/* script that can be used as a report-uri for HPKP.
   Will just send a mail to $hpkp_to with some info and json data
   Written by Hanno Böck, https://hboeck.de/
   License: CC0 / Public Domain
*/
 
$hpkp_to = "[insert email]";
$hpkp_log = "../hpkp.log";
 
$hpkp_info = "Host: ".$_SERVER['HTTP_HOST']."n";
$hpkp_info .= "Request URI: ".$_SERVER['REQUEST_URI']."n";
if ( array_key_exists('HTTP_REFERER', $_SERVER) ) {
$hpkp_info .= "Referrer: ".$_SERVER['HTTP_REFERER']."n";
}
$hpkp_info .= "Remote IP: ".$_SERVER['REMOTE_ADDR']."n";
$hpkp_info .= "User agent: ".$_SERVER['HTTP_USER_AGENT']."n";
$hpkp_info .= "CSP JSON POST data:nn";
$hpkp_info .= str_replace( ",", ",n", file_get_contents('php://input') );
$hpkp_info .= "nServer Info:nn";
$hpkp_info .= print_r($_SERVER, true);
 
 
mail($hpkp_to, "HPKP Warning from ".$_SERVER['HTTP_HOST'], $hpkp_info);
echo "ok";
 
if ($hpkp_log != "") {
	$f = fopen($hpkp_log, "a");
	fwrite($f, $hpkp_info);
	fclose($f);
}

Puppet: Hiera und Defined Types

puppetlabs apache vhost hiera howto

Das waren in etwa die Stichworte in der Suche als ich versucht habe meine mit dem puppetlabs/apache Modul definierten Apache vhosts aus der site.pp nach hiera zu migrieren. Es wollte nämlich einfach nicht klappen. Nach einiger Zeit bin ich dann darauf gestoßen, dass es sich bei dem apache::vhost um einen sogenannten Defined Type handelt. Diese sind sehr ähnlich zu Klassen, lassen sich aber im Gegensatz zu diesen mehrfach auf einem Node definieren.

Das automatische Parameter-Lookup wie bei den Klassen funktioniert bei Defined Types hingegen nicht. Lösung ist die create_resources() Funktion. Aber damit ich das später auch noch verstehe wenn ich hier in den Blogeintrag hineingucke schreibe ich ein praktisches Beispiel auf.

Ein Apache vhost in der site.pp:

node 'www.example.org' inherits default {
apache::vhost {'www.example.org':
  servername    => 'www.example.org',
  serveraliases => [ 'example.org' ],
  serveradmin   => 'webmaster@example.org',
  port          => '80',
  docroot       => '/var/www/example.org/www/',
}

wird in hiera zu:

---
apache::vhost:
  'www.example.org':
    servername: 'www.example.org'
    serveraliases:
      - 'example.org'
    serveradmin: 'webmaster@example.org'
    port: '80'
    docroot: '/var/www/example.org/www/'

und damit dieser Eintrag auch ausgewertet und der vhost erzeugt wird sieht die site.pp danach wie folgt aus:

node 'www.example.org' inherits default {
  $myApacheVhosts = hiera('apache::vhost', {})
  create_resources('apache::vhost', $myApacheVhosts)
}

Ich empfehle hier wärmstens das Chapter 3 – Using Hiera von Puppet Lunch. Ich habe lange im Netz gesucht und bin schließlich dort fündig geworden.