Serververwaltung mit Ansible
Ansible
Einleitung
Wer Server betreibt wird sich früher oder später die Frage stellen, wie diese Server möglichst sauber (und ggf. auch möglichst homogen) konfiguriert werden können. Weil das so ist, gibt es hierfür auch gleich eine ganze Reihe von entsprechenden Tools, z.B. SaltStack1, Ansible2, Puppet3, Chef4, CFEngine5 und Terraform6, um nur einige zu nennen. Persönlich habe ich sehr viel mit SaltStack gearbeitet, aber auch Ansible kam hier und da zum Einsatz. Im ersten Moment wirken SaltStack und Ansible auch sehr ähnlich. Beide setzen stark auf YAML7 und Jinja8. Unter der Haube unterscheiden sie sich aber dann doch recht deutlich. Salt setzt auf einen zentralisierten Ansatz mit eigenem Server, Clients und entsprechendem Protokoll, Ansible nutzt einfach SSH-Verbindungen und benötigt keinen zentralen Server zur Steuerung. Ansible ist zudem stärker darauf ausgelegt in den sog. Tasks Bezug auf vorangegangene Schritte zu nehmen dadurch fühlt sich Ansible (für mich) etwas "scriptiger" an. Ob man das gut oder schlecht findet, ist vermutlich Geschmackssache.
An sich mag ich den zentralistischen Ansatz von Salt sehr gern, durch das eigene Protokoll ist Salt auch um einiges schneller als Ansible, welches ja jeden einzelnen Step über eine SSH-Verbindung ausführen muss. Aber gerade wenn man z.B. gar keinen zentralen Server für die Verwaltung der Infrastruktur bereitstellen kann oder möchte, ist Ansible mir hier lieber (auch wenn es salt-ssh9 gibt). Deswegen und weil ich einfach gerne mal wieder etwas mit Ansible machen wollte, habe ich mein kleines Heimsetup in den letzten Wochen unter Ansibleverwaltung gestellt.
Inventory
Eine der ersten Dinge die man sich überlegen sollte, wenn man mit Ansible arbeiten möchte, ist der Aufbau und die Struktur des Ansible Inventories10 und der sog. Playbooks11 bzw. Roles12.
Das Inventory enthält die zu verwaltenden Systeme sowie die Zuweisung der Systeme in Gruppen auf welche die Playbooks/Roles angewendet werden sollen. Ein Inventory kann sehr simpel als einzelne Textdatei umgesetzt, aber auch (ggf. mit Hilfe von Inventory-Plugins13) dynamisch14 und in einer Verzeichnisstruktur15 verwaltet erzeugt werden. Letzteres ist gerade in Cloudumgebungen interessant, wenn z.B. Systeme lastabhängig erzeugt oder verworfen werden. Für meine Zwecke zuhause reicht natürlich ein simples Inventory (inventory.ini
) vollkommen aus:
[all]
server1
server2
localhost ansible_become_pass='{{ become_pass }}'
[server]
server1
server2
[local]
localhost ansible_become_pass='{{ become_pass }}'
Die verwendeten Namen passen zu denen in meiner SSH-Config, man kann hier natürlich auch FQDN oder IP-Adressen nutzen. Auf server1
und server2
melden ich mich direkt als root
User an, auf localhost
arbeite ich natürlich mit einem normalen User und daher gebe ich hier ein Kennwort an, um erweiterte Rechte zu erlangen (hierzu später mehr).
Verzeichnisstruktur
Einleitung
Als nächstes benötigt man eine Zuweisung der Gruppen zu den Rollen/Playbooks. Im Grunde kann man diese auch direkt ins Playbook schreiben, das wird aber spätestens dann unhandlich, wenn man halbwegs viele Playbooks nutzt und weitere Geräte hinzukommen bzw. entfernt werden oder sich Zuweisungen ändern. Ich empfehle hierzu eine separate Datei (bzw. separates Playbook), in der wirklich nur die Zuweisung von Gruppen zu Rollen vorgenommen wird. So wird es erheblich einfacher den Überblick darüber zu behalten was auf welchen Systemen ausgeführt wird.
Des weiteren hat man die Wahl relativ viele Informationen direkt ins Playbook zu schreiben, oder auf eine Verzeichnisstruktur zurückzugreifen, in der die einzelnen Playbookabschnitte in verschiedene Ordner und Dateien verteilt werden. Diese Unterscheidung kann manchmal etwas verwirrend sein, insbesondere wenn man gerade damit anfängt sich in Ansible einzuarbeiten. Denn viele Beispiele beziehen sich auf die eine oder andere Variante und auch das Ansible-Handbuch bleibt hier oft etwas vage. Generell macht die große Flexibilität von Ansible die Lernkurve etwas steiler, aber es lohnt sich und nach und nach fügen sich die Puzzleteile zu einem stimmigen Gesamtbild.
Ich persönlich bevorzuge die Variante mit der Verzeichnisstruktur16, nicht zuletzt weil sich meine Playbooks sonst schnell wie eine lose Sammlung von Scripten anfühlen.
Meine Verzeichnisstruktur sieht wie folgt aus:
.
├── group_vars
│ └── server.yml
├── host_vars
│ ├── server1
│ │ └── vault
│ ├── server2
│ │ └── vault
│ └── localhost
│ └── vault
├── roles
│ ├── example1
│ │ ├── defaults
│ │ └── main.yml
│ │ ├── files
│ │ └── example.conf
│ │ ├── handlers
│ │ └── main.yml
│ │ ├── tasks
│ │ └── main.yml
│ │ └── templates
│ │ └── example.j2
│ ├── example2
│ │ ├── defaults
│ │ └── main.yml
│ │ ├── files
│ │ └── example.conf
│ │ ├── handlers
│ │ └── main.yml
│ │ ├── tasks
│ │ └── main.yml
│ │ └── templates
│ │ └── example.j2
├── ansible.cfg
├── inventory.ini
└── site.yml
Variablen
In group_vars
liegen Dateien mit Variablendefinitionen, die für alle Hosts einer - im Inventory definierten - Gruppe gleichermaßen gelten. In host_vars
liegen Dateien mit Definitionen, die nur für einzelne Hosts gelten.
Roles
In roles
liegen die eigentlichen Playbooks, aufgeteilt auf verschiedene Abschnitte:
- defaults: Defaultwerte für Variablen (diese werden ggf. durch
group_vars
bzw.host_vars
überschrieben - files: Hier können beliebige Dateien abgelegt werden, die z.B. auf die Hosts kopiert werden sollen
- handlers: Hier können sog. Handler17 hinterlegt werden. Diese können aus einem Task mittels
notify
18 aufgerufen werden - tasks: Hier werden die eigentlich Schritte definiert, welche die Konfiguration des Hosts beschreiben
- templates: Hier können Jinja19 Templates abgelegt werden
Nicht jede Rolle braucht alle diese Verzeichnisse/Dateien. Gibt es keine Default, Files, Templates oder Handler, können diese natürlich einfach weggelassen werden. Lediglich ein tasks
Verzeichnis mit einer main.yml
wird zwingend benötigt.
Ansible Konfiguration
Die ansible.cfg
20 enthält vor allem Angaben, die es mir bei der Ausführung von Ansible erspart zwingend nötige - oder zumindest hilfreiche - Parameter anzugeben.
[defaults]
inventory = ./inventory.ini
roles_path = ./roles
vault_password_file = ./.vault.passwd
interpreter_python = /usr/bin/python3
pipelining = true
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
Hier die Erläuterungen zu den Einstellungen:
inventory
: Gibt die Inventory-Datei an, kann ansonsten mittels-i
Parameter übergeben werdenroles_path
: Gibt den Pfad zu den roles anvault_password_file
: Die Kennwortdatei füransible-vault
21interpreter_python
: Der Pfad zum Python-Binarypipelining
: Erhöht bestenfalls die Ausführgeschwindigkeit der Tasks erheblichssh_args
: Argumente für SSH, die oben aufgeführten beschleunigen den Verbindungsaufbau
Das zentrale Playbook / site.yml
Die site.yml
ist das zentrale Playbook, welches die Ausführung der roles
steuert. Es verbindet die Rollen mit den im Inventory definierten Gruppen und legt die Reihenfolge der Ausführung fest:
- hosts: server1
gather_facts: false
roles:
- {role: "zfs", tags: "zfs"}
- hosts: all
gather_facts: true
roles:
- {role: "apt", tags: "apt"}
- {role: "std_tools", tags: "tools"}
- {role: "ssh", tags: "ssh"}
- {role: "git", tags: "git"}
- {role: "scripts", tags: "scripts"}
- {role: "zsh", tags: "zsh"}
- {role: "cron", tags: "cron"}
- hosts: server
gather_facts: false
roles:
- {role: "dehydrated", tags: "dehydrated"}
- {role: "nginx", tags: "nginx"}
- {role: "postfix", tags: "postfix"}
- {role: "docker", tags: "docker"}
- hosts: server2
gather_facts: false
roles:
- {role: "grafana", tags: "grafana"}
- {role: "prometheus", tags: "prometheus"}
- {role: "checkmk", tags: "checkmk"}
Die angegebenen hosts
entsprechen den Servern oder Gruppen aus dem Inventory. Die roles
entsprechen den Verzeichnisnamen unterhalb des roles
Verzeichnis. Die Anweisung gather_facts
gibt an ob Ansible gewisse Informationen zu den Hosts (die sog. facts
22) bereitstellen soll. Hierbei handelt es sich um Variablen, die die Eigenschaften der Hosts beschreiben (z.B. Informationen zum Betriebssystem, Hardware, IP-Adressen, etc.). Das Erzeugen der Facts dauert eine Weile und sollte daher nur dann durchgeführt werden, wenn diese wirklich benötigt werden. Die tags
erlauben es nur bestimmte roles
auszuführen, da es ziemlich lange dauern kann, bis das vollständige Playbook angewendet wurde.
Playbook Beispiel 1
Gut, aber wie sieht so ein Playbook denn jetzt aus? Wie oben bereits erwähnt, gibt es mehrere Möglichkeiten für die Struktur eines Playbooks. In der Single-File-Variante werden Tasks, Handlers, etc. in eine einzige YAML-Datei geschrieben. Bei der Variante mit einem Role-Verzeichnisbaum werden diese Abschnitte in Ordner unterteilt. Diese Variante nutzen wir. Ein Playbook beginnt hier immer mit einem Verzeichnis unterhalb von roles_path
aus der ansible.cfg
. In diesem Verzeichnis muss sich mind. ein weiteres Verzeichnis mit dem Namen tasks
befinden. In diesem Verzeichnis erstellen wir dann eine main.yml
.
Nennen wir unser Verzeichnis unterhalb des roles_path
einmal apt
und erzeugen uns dort folgende tasks/main.yml
:
ansible.builtin.apt:
name: "*"
state: latest
update-cache: true
cache_valid_time: 3600
become: true
become_user: root
become_method: sudo
register: result
retries: 3
delay: 10
until: result is not failed
Dieser einzelne Task würde unsere mittels apt
installierten Pakete auf den neuesten Stand bringen. Wir nutzen hierfür das Ansible Modul ansible.builtin.apt
23, welches wir auch einfach mit apt
abkürzen können. Die vollständige Schreibweise mit ansible.builtin
schützt uns aber vor eventuell Namenskonflikten, wenn wir zusätzliche Module nachinstallieren. Die Anweisung name: "*"
sorgt dafür, dass alle vorhandenen Pakete für den Tasks berücksichtigt werden. Hier könnte alternativ ein einzelner Paketname aufgeführt werden. state: latest
sorgt dafür, dass das genannte Paket nicht nur installiert sondern auf die letzte verfügbare Version aktualisiert wird. Um unsere Versionsinformationen aktuell zu halten, aktualisieren wir unseren Cache mittels update-cache: true
. Da dieser Vorgang einige Zeit in Anspruch nimmt, tun wir dies nur dann, wenn unser Cache älter als 3600 Sekunden ist (cache_valid_time: 3600
). Natürlich sind hierfür root-Rechte erforderlich. Da wir Ansible ggf. nicht als root
User starten, nutzen wir die Option become
24 um zu root
zu werden und nutzen hierzu sudo
. Manchmal kann es vorkommen, dass gerade andere Prozesse auf unsere apt-Datenbank zugreifen. Daher merken wir uns mittels register: result
den Status unseres Tasks und wiederholen dessen Ausführung maximal 3 mal (retries: 3
) und warten zwischen den Versuchen jeweils 10 Sekunden (delay: 10
), wenn unser result
den Status failed
haben sollte (until: result is not failed
).
Ansible Befehle
ansible-playbook
Damit haben wir auch schon unser erstes funktionsfähiges Playbook. Da wir dieses ja bereits in unsere site.xml
im Abschnitt hosts: all
als role
mit dem Tag apt
hinterlegt haben, können wir dieses auch gleich einmal mit folgendem Befehl ausführen:
ansible-playbook site.yml --tags apt -K
Der Parameter --tags apt
sorgt dafür, dass nur unsere apt
Role ausgeführt wird. Der Parameter -K
fragt uns nach unserem Benutzerkennwort, um sudo
nutzen zu können. Der -K
Parameter ist zwar eine gute Lösung um das Kennwort nirgends speichern zu müssen, allerdings müsste man so dasselbe Kennwort auf allen Hosts nutzen und außerdem verhindert es natürlich die Automatisierung, weil es sich ja um eine manuelle Eingabe handelt. Hier kommt die Zeile localhost ansible_become_pass='{{ become_pass }}'
aus unserem Inventory ins Spiel (bei den beiden Servern fehlt diese, da wir uns hier eh direkt als root
einloggen und dort also auf become
verzichten können).
Wer Jinja (oder auch Twig) kennt, wird hier sofort erkennen, dass es sich bei {{ become_pass }}
um eine Variable handelt. Damit diese entsprechend befüllt wird, müssen wir den Wert der Variable natürlich irgendwo definieren. Da es sich hierbei um eine hostspezifische Variable handelt, können wir diese ganz einfach in der Datei ./host_vars/localhost/vault
speichern. Der Name vault
suggeriert hierbei schon, dass es sich hierbei um eine geschützte Datei handelt. Alternativ könnten wir z.B. auch eine (zunächst) ungeschützte Datei ./host_vars/localhost.yml
erzeugen.
Wenn ich meine Playbooks/Roles erst einmal nur testen möchte, kann ich den Parameter --check
nutzen, in diesem Modus führt Ansible die Befehle in den Tasks nicht wirklich aus. Ebenfalls hilfreich ist der Parameter --diff
, dieser zeigt die vorgenommenen (bzw. in Kombination mit --check
vorzunehmenden) Änderungen durch die Tasks an. Möchte ich meine Tasks darüberhinaus nur auf einem bestimmten Hosts ausführen, ist die mit dem Parameter --limit
möglich, --limit server1
führt die Tasks so z.B. nur auf server1
aus.
ansible-vault
Um eine geschützte vault
Datei zu erzeugen, generieren wir uns zunächst eine Kennwortdatei für unseren Ansible-Datentresor, dies kann z.B. mit dem Befehl pwgen -1 4096 > .vault.passwd
geschehen. Da wir diese Datei in unserer ansible.cfg
bereits entsprechend angegeben haben, müssen wir diese nicht mittels Parameter an unsere Ansible-Befehle übergeben.
Um nun den eigentlichen Vault zu erzeugen, nutzen wir den folgenden Befehl:
ansible-vault create host_vars/localhost/vault
Die Datei öffnet sich daraufhin in unserem Standardeditor. Hier können wir nun unsere become_pass
Variable definieren:
become_pass: meinsupergeheimesbenutzer/sudokennwort
Speichern wir die Datei und schließen unseren Editor, verschlüsselt ansible-vault
25 die Datei und sie sollte danach in etwa wie folgt aussehen:
$ANSIBLE_VAULT;1.1;AES256
62623633323633613439623465653865613761346536663432393239396131666133323434656465
6137366462363832356664323439383838363838626665350a663335613032366465323765356632
64383931323534393864663232623664356234376434396338346438343633643531636461386235
3134313763653361350a646138616466663065383761303962353962616338343230373133316334
32626465363039613434383634323239313632393030393032326161396133336231653366333931
35353338313862623138613166306331306563393762356166306237353032356431643734373137
643662663335343835353663303030363366
Möchte man die Datei erneut bearbeiten, kann sie mit dem Befehl ansible-vault edit ./host_vars/localhost/vault
entschlüsselt und geöffnet werden.
Anstatt die gesamte host_vars
Datei eines Hosts zu verschlüsseln, kann man mit ansible-vault
auch einzelne Variablen verschlüsseln, hierzu nutzt man den Befehl ansible-vault encrypt_string
, gibt danach den zu verschlüsselnden Wert an und drück (ggf. 2 x) die Tastenkombination Strg+d
:
ansible-vault encrypt_string
Reading plaintext input from stdin. (ctrl-d to end input, twice if your content does not already have a newline)
meinsupergeheimesbenutzer/sudokennwort
Encryption successful
!vault |
$ANSIBLE_VAULT;1.1;AES256
30343733363164313938616537326631366137353830386338626137383633393964656536343430
6261333436313961396539626263363861393037626534640a306138306366356439383035656231
65333139643732363565353933343764353763663261396232373163343762636533393231613964
3532323935663833320a326536343733363435396133656234623539656661653835323334656532
36316336636133323638363237643638306461393666646363363766393937633363323936333464
6162663933646532643265313262373138373264616338313865
Den Output kann man dann in ./host_vars/localhost.yml
wie folgt einfügen:
become_pass: !vault |
$ANSIBLE_VAULT;1.1;AES256
30343733363164313938616537326631366137353830386338626137383633393964656536343430
6261333436313961396539626263363861393037626534640a306138306366356439383035656231
65333139643732363565353933343764353763663261396232373163343762636533393231613964
3532323935663833320a326536343733363435396133656234623539656661653835323334656532
36316336636133323638363237643638306461393666646363363766393937633363323936333464
6162663933646532643265313262373138373264616338313865
Welche Variante man nutzt ist gewissenmaßen Geschmackssache. Eine komplett verschlüsselte Vaultdatei sorgt u.a. dafür, dass man nicht vergisst bestimmte Informationen zu verschlüsseln und schützt auch Informationen, die man vielleicht gar nicht als Schützeswert erkannt hatte. Dafür muss man natürlich zum Bearbeiten immer zum ansible-vault edit
-Befehl greifen. Außerdem verändert sich die Vaultdatei bei jedem Aufruf von ansible-vault edit
, selbst dann, wenn keine Änderungen vorgenommen wurden (dies könnte man verhindern, indem man einen eigen salt
in der ansible.cfg
definiert, aber dann nutzen alle Vaults denselben salt
und sind somit ggf. etwas leichter angreifbar).
ansible-galaxy
Neben den Ansible builtin
Modulen26 gibt es auch noch zahlreiche community
Module27 bzw. Plugins. Diese lassen sich mit ansible-galaxy
28 verwalten29. Neben den Plugins (auch collections
genannt) stehen auch vorgefertigte roles
zur Verfügung.
Mit ansible-galaxy collections list
listet man die verfügbaren Collections auf. Ich nutze z.B. gern und viel community.dns
und community.docker
. Die Collections lassen sich mittels ansible-galaxy install community.docker
installieren. Sie können anschließend genau so in Tasks genutzt werden, wie die ansible.builtin
Module.
Mit ansible-galaxy rules search
kann man nach vorgefertigten Rollen suchen. Möchte man z.B. Apache installieren und verwalten, kann man mittels ansible-galaxy rules search apache webserver
nach entsprechenden Rollen oder Vorlagen suchen. Die mit ansible-galaxy role install
installierten Roles werden im über unsere ansible.cfg
definierten roles_path
abgelegt.
ansible-lint
Die Syntax in den Ansible-Dateien ist sehr wichtig, oft funktionieren zwar auch nicht ganz sauber geschriebene Playbooks tadellos, es gibt aber keine Garantie, dass dies nach einem Update von Ansible immer noch der Fall ist. Daher sollte man hier gut darauf achten sauber zu bleiben. Man erspart sich so eine Menge Arbeit und Fehlersuche. Unterstützen kann hierbei ansible-lint
30 und yamllint
31, die gleichnamigen Pakete sind nicht Teil der Ansible Standardinstallation und müssen daher separat nachinstalliert werden.
Mit ansible-lint
kann geprüft werden, ob die eigenen Dateien den Syntaxregeln und Best-Practice Vorgaben entsprechen.
> ansible-lint
Passed with production profile: 0 failure(s), 0 warning(s) on 292 files.
Mitunter kann ansible-lint
und insbesondere yamllint
sehr streng sein. Es ist daher möglich über die Datei .ansible-lint
die Regeln der beiden Programme zu beeinflussen:
skip_list:
- package-latest
warn_list:
- yaml
- ignore-errors
In dem obigen Beispiel wird der "Fehler" die Anweisung state: latest
in einem ansible.builtin.apt
Task zu bemängeln abgeschaltet. Außerdem werden alle YAML-Fehler und die Verwendung von ignore_errors: true
von Fehlern zu Warnungen heruntergestuft.
Playbook Beispiel 2
Einleitung
Aber wenden wir uns nun einem komplexerem Playbook Beispiel zu. Ich nutze auf meinen Server letsencrypt
für meine SSL/TLS-Zertifikate. Um diese zu Erzeugen und Aktuell zu halten, nutze ich das Tool dehydrated
. Hierzu sind natürlich das Tool selbst als auch ein paar Konfigurationsdateien notwendig. Und natürlich benötigen meine Server unterschiedliche Zertifikate, wir brauchen als auch entsprechende Variablen. Daher ist die Verzeichnisstruktur unserer dehydrated
Role auch etwas komplexer:
roles/dehydrated
├── defaults
│ └── main.yml
├── handlers
│ └── main.yml
├── tasks
│ └── main.yml
└── templates
├── config.j2
├── domains.txt.j2
└── hook.sh.j2
Variablen
Defaults
Beginnen wir mit unseren Default-Variablen in defaults/main.yml
:
dehydrated_challenge: "dns-01"
dehydrated_ocsp: true
dehydrated_options: "--ipv4 --ocsp"
dehydrated_cron_mailto: "dehydrated@example.org"
dehydrated_wellknown: "/var/www/html/.well-known/acme-challenge"
dehydrated_reload:
- /etc/init.d/nginx reload
- /usr/sbin/postfix reload
dehydrated_provider: "clownflare"
dehydrated_provider_delay: 30
dehydrated_domains:
- example.org www.example.org
dehydrated_provider_token: false
host_vars
Diese Werte würden genutzt, sofern in den host_vars
bzw. group_vars
keine eigenen Definitionen für die jeweiligen Variablen enthalten sind. Da ich auf server1
und server2
unterschiedliche Zertifikatsnamen benötige, lege ich eine solche abweichende Definition für dehydrated_domains
in ./host_vars/server1/vault
und ./host_vars/server2/vault
bzw. ./host_vars/server1.yml
und ./host_vars/server2.yml
an:
Server1:
dehydrated_domains:
- "*.intern.example.org`
Server2:
dehydrated_domains:
- "*.example.org`
Überschrieben wird hierbei also nur der Wert für dehydrated_domains
alle weiteren Variablen bezieht Ansible weiterhin aus der defaults/main.yml
.
group_vars
Desweiteren benötigen wir noch einen Wert für die Variable dehydrated_provider_token
. Dieser ist für alle Server identisch, daher können wir ihn als group_vars
definieren. Die Group Vars befinden sich in ./group_vars/
und sind nach einer Gruppe von Hosts benannt, die im Inventory definiert wurden (z.B. server
). In diesem Fall ./group_vars/server.yml
:
dehydrated_provider_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
65623165383738383438386330663633386565336633643963313463336661393564613162336332
3262306666616336366434366331613730386430666331390a313438633139346532313938313265
31626637386265633633623838643931393862613261636535613162613964376563333266303666
3331306535626233650a326531653434653562646263343735626331646236306164343530303962
38356236303361393836313262303062386130653062393064633136613564616565353562316436
3333323064653830653230393033636239313139363533303431
Die Variablen nutze ich in den Konfigurationsdateien von dehydrated
. Diese befinden sich im Ordner templates
meiner dehydrated
-role.
Templates
Templates nutzten die Templatesprache jinja
und werden im Ordner templates
abgelegt. Ich nenne sie dort in der Regel nach dem späteren Dateinamen, hänge an diesen jedoch die Endung .j2
für jinja2
an:
config.j2:
CONFIG_D=/etc/dehydrated/conf.d
BASEDIR=/var/lib/dehydrated
WELLKNOWN="{{ dehydrated_wellknown }}"
DOMAINS_TXT="/etc/dehydrated/domains.txt"
HOOK=/etc/dehydrated/hook.sh
{% if dehydrated_ocsp %}
OCSP_MUST_STAPLE="yes"
OCSP_FETCH="yes"
OCSP_DAYS=5
{% endif %}
AUTO_CLEANUP="yes"
Dies ist die Konfigurationsdatei von dehydrated
. Hier fügt Jinja den in den Variablen (defaults
, group_vars
oder host_vars
) definierten dehydrated_wellknown
Pfad ein und - falls dehydrated_ocsp
gesetzt ist (dehydrated_ocsp: true
z.B. aus defaults/main.yml
) - werden entsprechende Konfigurationseinträge eingefügt.
domains.txt.j2:
{% for domain in dehydrated_domains %}
{{ domain }}
{% endfor %}
Hier werden die Domains eingetragen, für die dehydrated
Zertifikate erzeugen soll. Hier haben wir ja entsprechende Einträge für unsere beiden Server in den host_vars
vorgenommen, hier bei ist zu beachten, dass wir die Domains als Liste definiert haben:
dehydrated_domains:
- "*.intern.example.org`
Erkennbar ist dies durch das vorangestellte -
. Dies sorgt dafür, dass wir im Template ganz einfach mittels for
-Schleife über die Werte iterieren können.
hook.sh.j2:
#!/bin/bash
[...]
{% if dehydrated_challenge == 'dns-01' %}
export PROVIDER_UPDATE_DELAY=${PROVIDER_UPDATE_DELAY:-"{{ dehydrated_provider_delay }}"}
export PROVIDER=${PROVIDER:-"{{ dehydrated_provider }}"}
export LEXICON_HETZNER_TOKEN={{ dehydrated_provider_token }}
[...]
{% else %}
[...]
{% endif %}
{% if dehydrated_ocsp %}
function deploy_ocsp {
echo " + Hook: Reloading services (OCSP)..."
{% for service in dehydrated_reload %}
{{ service }}
{% endfor %}
}
{% endif %}
[...]
function deploy_cert {
{% for service in dehydrated_reload %}
{{ service }}
{% endfor %}
}
[...]
Bei hook.sh
handelt es sich um ein Script, welches dehydrated
ausführt, wenn bestimmte Schritte im Zerifizierungsprozess durchlaufen werden. Den eigentlichen Code des Scripts habe ich weitgehend entfernt, denn es geht hier ja nicht um die Funktion des Scripts, sondern darum, welche Aufgaben Jinja/Ansible hier übernimmt, um das Script zu erzeugen. Die EXPORT
s am Beginn des Scripts benötigen wir nur, wenn wir letsencrypt
in Verbindung mit dem sog. dns-01
Verfahren nutzen. Daher werden diese Angaben auch nur dann in den Code geschrieben, wenn dehydrated_challenge
dem Wert dns-01
entspricht. Die eigentlichen Werte für die Exports stammen ebenfalls aus Jinja-Variablen, da diese natürlich für jeden Server unterschiedlich sein können. Die Funktion deploy_ocsp
benötigen wir nur, wenn wir OCSP nutzen (also dehydrated_ocsp
den Wert true
bzw. 1 oder "wahr" entspricht). Bei bestimmten Vorgängen müssen Dienste neu gestartet bzw. deren Konfiguration neu eingelesen werden. Welche das sind, definieren wir wieder als Liste über die Variable dehydrated_reload
. Da auf unseren beiden Servern nginx
und postfix
genutzt wird, können wir die defaults
so belassen. Sollte das nicht der Fall sein, können wir natürlich in den hosts_vars
unterschiedliche Werte für unsere Server setzen.
Tasks
Nun aber zu den eigentlichen Tasks, die ausgeführt werden müssen, um dehydrated
zu Installieren und Einzurichten. Diese befinden sich in tasks/main.yml
. Alle Tasks befinden sich in dieser Datei, ich werde sie hier aber in mehreren Abschnitten aufgeteilt aufführen, damit ich die Erläuterungen direkt darunter schreiben kann:
- name: "Install dehydrated"
ansible.builtin.apt:
name: dehydrated
state: present
register: result
retries: 3
delay: 10
until: result is not failed
Zunächst müssen wir natürlich dehydrated
selbst einmal installieren. Es steht uns über das offizielle Debian-Repo zur Verfügung, wir können also ansible.builtin.apt
nutzen. Anders als im Beispiel oben nutzen wir hier natürlich nun nicht mehr name: "*"
sondern name: dehydrated
um das gleichnamige Paket zu installieren. Auch ist uns hierbei nicht weiter wichtig, ob es die aktuellste Version sind, sondern nur dass es überhaupt installiert ist state: present
. Auch hier nutzen wir wieder die retries
-Funktion, die uns vor einem Fehler im Ansible-Ablauf bewahren soll, falls die apt/dpkg Umgebung auf dem Host gerade anderweitig beschäftigt sein sollte.
- name: "Install lexicon"
ansible.builtin.apt:
name: lexicon
state: present
when: dehydrated_challenge == "dns-01"
register: result
retries: 3
delay: 10
until: result is not failed
Hier passiert im Grunde dasselbe wie oben, nur eben mit dem Paket lexicon
allerdings ist die Ausführung dieses Tasks an eine Bedingung geknüpft: when: dehydrated_challenge == "dns-01"
Der Task wird also nur ausgeführt, wenn dehydrated_challenge
für den jeweiligen Host auf dns-01
gesetzt wurde. Andernfalls ist dieses Paket nämlich nicht notwendig.
- name: "Configure dehydrated"
ansible.builtin.template:
dest: /etc/dehydrated/config
src: templates/config.j2
mode: "0640"
notify:
- "Run dehydrated"
Dieser Tasks schreibt die Datei /etc/dehydrated/config
und nutzt dazu das Template config.j2
. Zudem setzt es die Dateirechte auf 640
, die führende Null ist bei Ansible sehr wichtig, ohne sie kommen völlig andere Dateirechte zustande und die Funktion der meisten Programme ist dahin oder zumindest eingeschränkt. Da die Konfigurationsdatei natürlich auch Einfluss auf die Funktion von dehydrated
hat, führen wir selbiges immer einmal aus, wenn sich die Konfiguration geändert hat. Hierzu dient die Anweisung notify
32. notify
kann einen oder mehrere handlers
aufrufen, in unserem Fall wird der Handler Run dehydrated
aufgerufen, hierzu später mehr.
- name: "Write hook.sh"
ansible.builtin.template:
dest: /etc/dehydrated/hook.sh
src: templates/hook.sh.j2
mode: "0750"
Auch hier wird aus einem Template heraus eine Datei erzeugt, diesmal das hook.sh
Script. Dieses muss natürlich ausführbar sein und dementsprechend setzen wir die Dateirechte auf 750
. Da eine Änderung am Hook in der Regel keine erneute Ausführung von dehydrated
erfordert, verzichten wir an dieser Stelle auf ein notify
.
- name: "Write domains.txt"
ansible.builtin.template:
dest: /etc/dehydrated/domains.txt
src: templates/domains.txt.j2
mode: "0640"
notify:
- "Run dehydrated"
Und eine weitere Datei auf Templatebasis. Hier werden die Domains konfiguriert, für die wir Zertifikate erstellen möchten. Natürlich sollte nach einer Änderung an dieser Datei ein erneuter Lauf von dehydrated
folgen (notify
).
- name: "Create .well-known directory"
ansible.builtin.file:
path: "{{ dehydrated_wellknown }}"
state: directory
mode: "0755"
owner: www-data
group: www-data
when: dehydrated_challenge == "http-01"
Neben dem bereits erwähnten dns-01
Verfahren kann auch das http-01
Verfahren für letsencrypt genutzt werden. Sollte dies für einen Hosts angegeben sein (when
), benötigen wir ein Verzeichnis im Docroot des Webservers, diesen haben wir als dehydrated_wellknown
definiert.
- name: "Create cronjob"
ansible.builtin.cron:
name: "dehydrated"
special_time: daily
job: >
/usr/bin/dehydrated --cron --challenge {{ dehydrated_challenge }} {{ dehydrated_options }} 2>&1
| mail -s "$(hostname) Dehydrated status" {{ dehydrated_cron_mailto }}
Da unsere Zertifikate nach 90 Tagen ablaufen, müssen wir diese natürlich regelmäßig erneuern. Dies kann über einen Cronjob erfolgen. Diesen legen wir wie oben an. Wichtig ist hierbei die Angabe von name
. Dieser wird von Ansible als Kommentar in die Crontab geschrieben. So kann Ansible den Eintrag identifizieren und fügt diesen so bei Änderungen nicht einfach mehrfach in die Crontab ein. Da die job
Definition sehr lang ist, nutze ich hier >
dies sorgt dafür, dass die nachfolgenden (eingerückten) Zeilen bei der Ausführung wieder zu einer Zeile zusammengesetzt werden. Die Zeilenumbrüche werden hierbei durch Leerzeichen ersetzt.
Damit ist unsere Taskdefinition abgeschlossen.
Handlers
Aber wir rufen in den Tasks an mehreren Stellen einen Handler auf. Diesen müssen wir natürlich auch noch Anlegen. Er befindet sich in handlers/main.yml
- name: "Run dehydrated"
ansible.builtin.command: /usr/bin/dehydrated -c --challenge {{ dehydrated_challenge }} {{ dehydrated_options }}
Er führt ganz einfach den Befehl /usr/bin/dehydrated
mit entsprechenden Parametern aus.
Damit ist unsere Role/Playbook für dehydrated vollständig.
site.yml
In unserer site.yml
oben hatten wir unsere dehydrated
Rolle bereits eingetragen, der Vollständigkeit halber will ich es hier aber noch einmal erwähnen. Damit unsere Rolle ausgeführt wird, ist der Eintrag in der site.yml
nötig. Hierbei wird außerdem eine Zuweisung vorgenommen, auf welchen Hosts diese Rolle angewendet werden soll. Auf meinem Desktoprechner brauche ich solche Zertifikate ja in der Regel nicht (wobei man sich eh drüber streiten kann, ob man seinen Desktop mit Ansible verwalten will/soll, ich schreibe damit aber z.B. meine dotfiles). Der Eintrag sieht wie folgt aus:
- name: Server
hosts: server
gather_facts: false
roles:
- {role: "dehydrated", tags: "dehydrated"}
- {role: "nginx", tags: "nginx"}
- {role: "postfix", tags: "postfix"}
Die Rolle wird für die Hosts die im Inventory als server
Gruppe definiert wurden angewendet. Über den Tag dehydrated
ist es uns möglich anstelle aller Rollen (in diesem Beispiel also nginx
und postfix
) nur explizit die Rolle dehydrated
auszuführen. Dies spart Zeit.
Die Ausführung erfolgt mittels ansible-playbook site.yml --tags dehydrated --check --limit server1
. In diesem Beispiel führen wir keine echten Änderungen durch, sondern lassen uns nur ausgeben, was Ansible tun würde (--check
). Außerdem beschränken wir den Lauf auf server1
(--limit
).
Der Output:
❯ ansible-playbook site.yml --tags dehydrated --check --limit server1
PLAY [All] ****************************************************************************************************************************************************************************************************************************************************************************************************
TASK [Gathering Facts] ****************************************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
PLAY [Server] *************************************************************************************************************************************************************************************************************************************************************************************************
TASK [dehydrated : Install dehydrated] ************************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
TASK [dehydrated : Install lexicon] ***************************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
TASK [dehydrated : Configure dehydrated] **********************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
TASK [dehydrated : Write hook.sh] *****************************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
TASK [dehydrated : Write domains.txt] *************************************************************************************************************************************************************************************************************************************************************************
ok: [server1]
TASK [dehydrated : Create .well-known directory] **************************************************************************************************************************************************************************************************************************************************************
skipping: [server1]
TASK [dehydrated : Create cronjob] ****************************************************************************************************************************************************************************************************************************************************************************
changed: [server1]
PLAY [server1] ****************************************************************************************************************************************************************************************************************************************************************************************************
PLAY RECAP ****************************************************************************************************************************************************************************************************************************************************************************************************
server1 : ok=7 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Wir sehen hier, dass sich offenbar an unserer Cronjob-Definition etwas geändert hat, da hier ein change
vorliegt. Der Task Create .well-known directory
wurde übersprungen, da unser Server das dns-01
Verfahren nutzt. Alle anderen Tasks entsprechen bereits unseren Vorgaben und sind daher OK. Wenn wir genau sehen wollen welche Änderungen sich beim Cronjob ergeben haben, können wir den Parameter --diff
verwenden.
Damit haben wir unseren ersten Schritte in der Ansiblewelt getan. Ich könnte hier jetzt noch zig weitere Playbook/Rollen-Beispiele anbringen, aber der Artikel ist bereits jetzt länglich, daher habe ich mich entscheiden in den kommenden Tagen einfach ein paar weitere Ansible-Artikel zu posten. Diese werden dann auf besondere Probleme und deren Lösungsansätze bei der Playbookerstellung eingehen.
-
https://docs.saltproject.io/en/latest/topics/about_salt_project.html ↩
-
https://docs.saltproject.io/salt/user-guide/en/latest/topics/salt-ssh.html ↩
-
https://docs.ansible.com/ansible/latest/inventory_guide/index.html ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/index.html ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html ↩
-
https://docs.ansible.com/ansible/latest/plugins/inventory.html ↩
-
https://docs.ansible.com/ansible/latest/inventory_guide/intro_dynamic_inventory.html ↩
-
https://docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.html#organizing-inventory-in-a-directory ↩
-
https://docs.ansible.com/ansible/latest/tips_tricks/sample_setup.html ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_handlers.html#handlers ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_handlers.html#notifying-handlers ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_templating.html ↩
-
https://docs.ansible.com/ansible/latest/reference_appendices/config.html ↩
-
https://docs.ansible.com/ansible/latest/vault_guide/index.html ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_vars_facts.html ↩
-
https://docs.ansible.com/ansible/latest/collections/ansible/builtin/apt_module.html ↩
-
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_privilege_escalation.html ↩
-
https://docs.ansible.com/ansible/latest/vault_guide/index.html ↩
-
https://docs.ansible.com/ansible/2.9/modules/list_of_all_modules.html ↩
-
https://docs.ansible.com/ansible/latest/collections/community/general/index.html#plugin-index ↩
-
https://docs.ansible.com/ansible/latest/galaxy/user_guide.html ↩
-
https://docs.ansible.com/ansible/latest/user_guide/playbooks_handlers.html ↩