KI-generiertes Bild
03 November 2026

Root auf Zeit: Ansible in GitLab CI ohne statische SSH-Keys

Kategorien: Ansible Automatisierung
Tags: Ansible Gitlab
KI-Offenlegung:Dieser Inhalt wurde mit KI-Unterstützung geschrieben.

Wer seine Server mit Ansible verwaltet, landet früher oder später bei der Frage, ob die Playbooks nicht auch automatisiert aus einer CI/CD-Pipeline heraus laufen können. Liegt das Ansible-Repository ohnehin in GitLab, bietet sich GitLab CI dafür an: Repository, Merge Requests und Pipeline befinden sich an einer Stelle. Das gilt besonders für einen GitOps-Workflow: Das Git-Repository beschreibt den gewünschten Zustand der Infrastruktur, Änderungen werden per Merge Request geprüft, die Pipeline zeigt mit ansible-playbook --check --diff vorab, was sich ändern würde, und rollt die Änderung nach dem Merge aus. Ein regelmäßiger Check-Lauf deckt zusätzlich Abweichungen zwischen Repository und Servern auf.

Damit das funktioniert, muss sich Ansible per SSH an den Servern anmelden können, in der Regel mit einem Benutzer, der über sudo Root-Rechte erhält. In diesem Blogeintrag wollen wir uns anschauen, wie dieser Zugang ohne statischen SSH-Schlüssel auskommt.

Möglich macht das eine Funktion von GitLab CI: Jeder Job kann sich von GitLab ein signiertes ID-Token ausstellen lassen, das Informationen wie Projekt und Branch enthält. Dieses Token nutzen wir, um bei HashiCorp Vault ein SSH-Zertifikat zu erhalten, das 30 Minuten gültig ist. Dazu erzeugt jeder Job ein eigenes Schlüsselpaar und lässt es von einer SSH-CA in Vault signieren. Vault lässt sich dabei so konfigurieren, dass es diese Zertifikate nur für freigegebene Projekte und geschützte Branches ausstellt.

Problemstellung

Der verbreitete Weg sieht so aus: Ein privater SSH-Schlüssel wird als geschützte CI/CD-Variable in GitLab hinterlegt, der zugehörige öffentliche Schlüssel landet auf allen Servern in der authorized_keys. Das ist schnell eingerichtet, wirft im Betrieb aber eine Reihe von Fragen auf.

Zunächst gilt ein Schlüssel in der authorized_keys unbefristet. Soll der Zugang entzogen werden, muss der Schlüssel auf allen Servern entfernt werden. Welche Jobs den Schlüssel nutzen dürfen, regeln allein die Einstellungen der CI/CD-Variable in GitLab. Wer die Variable lesen kann, hat Zugang zu allen Servern, unabhängig davon, aus welchem Branch oder in welchem Kontext ein Job läuft.

Eng damit verbunden ist die Frage, wer den Schlüssel eigentlich noch alles verwendet. Ein privater Schlüssel lässt sich beliebig kopieren und von jedem Rechner aus verwenden, und genau das passiert im Alltag auch: Er wird auf einen Laptop kopiert, um einen fehlgeschlagenen Lauf nachzustellen, in einem weiteren Projekt oder Skript wiederverwendet oder landet im Passwortmanager eines Kollegen, der das Team inzwischen verlassen hat. Nach einiger Zeit weiß niemand mehr sicher, wo überall Kopien existieren. Damit ist auch unklar, wer sich mit diesem Schlüssel als Root an den Servern anmelden kann.

Hinzu kommt die Nachvollziehbarkeit. Alle Jobs melden sich mit demselben Schlüssel an. Im Log des Servers lässt sich ein Login aus der Pipeline weder einem konkreten Job zuordnen, noch von einem Login mit einer Kopie des Schlüssels unterscheiden. Eine zentrale Stelle, an der protokolliert wird, wann und für wen ein Zugang ausgegeben wurde, gibt es nicht. Der Schlüssel wird schließlich nicht ausgegeben, er ist einfach da.

Und schließlich die Rotation: Ein langlebiger Schlüssel müsste regelmäßig gewechselt werden. Neuen Schlüssel erzeugen, auf allen Servern ausrollen, in GitLab ersetzen, den alten entfernen. In der Praxis passiert das jedoch eher selten, nicht zuletzt weil ein Wechsel alle unbekannten Nutzer des Schlüssels trifft und dann erst auffällt, wo er überall im Einsatz war. Hier schließt sich der Kreis zur Frage, wer den Schlüssel eigentlich noch verwendet.

Eigentlich wünschen wir uns eine Regel wie: „Nur Jobs aus dem Projekt infra/ansible auf dem geschützten Branch main erhalten Zugang zu den Servern, nur für die Dauer des Jobs und nur von unseren Runnern aus.“

Aufbau

Um den oben genannten Problemen zu begegnen, kommen drei Bausteine zum Einsatz.

GitLab stellt jedem CI-Job auf Anforderung ein signiertes ID-Token aus. Dabei handelt es sich um ein JWT, das unter anderem das Projekt, den Branch und die Information enthält, ob der Branch geschützt ist.

HashiCorp Vault prüft dieses Token über die JWT-Auth-Methode. Passen die Angaben im Token zu einer zuvor definierten Rolle, erhält der Job ein Vault-Token. Mit diesem darf er über die SSH-Secrets-Engine, die in Vault als SSH-CA arbeitet, seinen öffentlichen Schlüssel signieren lassen.

Die Server vertrauen der SSH-CA und lassen einen dedizierten Benutzer ansible ausschließlich mit einem Zertifikat dieser CA zu.

Der Ablauf eines Jobs sieht damit wie folgt aus:

Der private Schlüssel entsteht im Job und verlässt diesen nie. Vault bekommt nur den öffentlichen Schlüssel zu sehen, GitLab keinen von beiden. In GitLab selbst ist kein Secret für den Serverzugang hinterlegt. Ob ein Job Zugang erhält, entscheidet Vault anhand der Angaben im ID-Token und nicht die Pipeline-Definition, die schließlich in jedem Branch geändert werden kann.

Einrichtung

Für die folgenden Beispiele setzen wir eine bestehende GitLab-Instanz mit Runner sowie Vault voraus. Die Community Edition von GitLab genügt. Vault muss die OIDC-Discovery-URL von GitLab erreichen können, die Runner-Jobs wiederum Vault. Zudem sollte in Vault ein Audit-Device aktiviert sein, damit jeder Login und jede Signatur protokolliert wird. Als Namen verwenden wir gitlab.example.com, vault.example.com, das Projekt infra/ansible und den Server-Benutzer ansible.

Wir richten Vault hier der Übersicht halber mit der vault-CLI ein. In einem GitOps-Setup bietet es sich natürlich an, auch diese Konfiguration im Repository zu pflegen und über die Pipeline mit Ansible oder Terraform/OpenTofu auszurollen.

GitLab und Vault koppeln

GitLab tritt für CI-Jobs als OIDC-Provider auf. Die ID-Tokens werden mit einem Schlüssel von GitLab signiert, die zugehörigen öffentlichen Schlüssel veröffentlicht GitLab unter /.well-known/openid-configuration. Vault muss also kein Secret mit GitLab teilen, sondern lediglich wissen, welchem Aussteller es vertrauen soll:

$ vault auth enable -path=gitlab jwt
Success! Enabled jwt auth method at: gitlab/

$ vault write auth/gitlab/config \
    oidc_discovery_url=https://gitlab.example.com \
    bound_issuer=https://gitlab.example.com
Success! Data written to: auth/gitlab/config

Über oidc_discovery_url ruft Vault die Signaturschlüssel von GitLab ab, bound_issuer stellt sicher, dass nur Tokens mit genau diesem Aussteller akzeptiert werden. Der Pfad gitlab ist frei wählbar. Er taucht später im Login-Pfad und im Namen der Vault-Tokens auf.

Interessant wird es beim Inhalt des ID-Tokens. Dekodiert sieht der Payload eines Tokens für einen Job auf main (gekürzt) wie folgt aus:

{
  "iss": "https://gitlab.example.com",
  "aud": "https://vault.example.com",
  "sub": "project_path:infra/ansible:ref_type:branch:ref:main",
  "iat": 1760890441,
  "nbf": 1760890436,
  "exp": 1760894041,
  "project_path": "infra/ansible",
  "ref": "main",
  "ref_type": "branch",
  "ref_protected": "true",
  "pipeline_source": "push",
  "user_login": "jdoe",
  "job_id": "4711",
  "ci_config_ref_uri": "gitlab.example.com/infra/ansible//.gitlab-ci.yml@refs/heads/main"
}

Neben den Standard-Claims iss, aud, sub und den Zeitstempeln finden sich darin die für uns interessanten Claims project_path, ref, ref_type und ref_protected. Auffällig ist, dass ref_protected als String und nicht als Boolean übertragen wird. Das ist später bei der Konfiguration der Vault-Rolle wichtig. Darüber hinaus stehen weitere Claims wie pipeline_source, user_login, environment oder ci_config_ref_uri zur Verfügung.

SSH-CA einrichten

Die SSH-Secrets-Engine von Vault kann als Zertifizierungsstelle arbeiten. Sie signiert öffentliche SSH-Schlüssel und erzeugt daraus OpenSSH-Zertifikate. Den Signaturschlüssel der CA lassen wir Vault selbst erzeugen, der private Teil verlässt Vault damit nicht:

$ vault secrets enable ssh
Success! Enabled the ssh secrets engine at: ssh/

$ vault write ssh/config/ca generate_signing_key=true key_type=ssh-ed25519
Key           Value
---           -----
public_key    ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...

Den öffentlichen Schlüssel der CA liefert Vault auch ohne Anmeldung aus. Wir benötigen ihn später auf den Servern:

$ curl https://vault.example.com/v1/ssh/public_key
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...

Welche Zertifikate die CA ausstellt, legen wir über eine Rolle fest:

$ vault write ssh/roles/ansible - <<'EOF'
{
  "key_type": "ca",
  "allow_user_certificates": true,
  "allowed_users": "ansible",
  "default_user": "ansible",
  "allowed_extensions": "permit-pty",
  "default_extensions": {"permit-pty": ""},
  "ttl": "30m",
  "max_ttl": "1h"
}
EOF
Success! Data written to: ssh/roles/ansible

Die Rolle ist dabei wie folgt aufgebaut: Sie stellt ausschließlich Benutzer-Zertifikate aus, und zwar nur für den Principal ansible. Als einzige Extension wird permit-pty gesetzt. Port-, Agent- und X11-Forwarding sind mit diesem im Zertifikat nicht möglich. Ein Zertifikat ist standardmäßig 30 Minuten gültig, auf Anfrage maximal eine Stunde. Versucht ein Client, davon abzuweichen, lehnt Vault die Anfrage ab:

$ vault write ssh/sign/ansible public_key=@id_ed25519.pub valid_principals=root
…
* root is not a valid value for valid_principals

$ vault write ssh/sign/ansible public_key=@id_ed25519.pub ttl=2h
…
* ttl is larger than maximum allowed 3600

Optional lässt sich über key_id_format noch die Key ID im Zertifikat beeinflussen. Ohne weitere Konfiguration enthält sie einen Hash des Vault-Tokens (vault-token-<hash>). Mit key_id_format='{{token_display_name}}' steht dort nach einem Login über unsere JWT-Auth-Methode stattdessen gitlab-infra/ansible. Da sshd die Key ID bei jedem Login protokolliert, ist das im Log deutlich aussagekräftiger.

Zusätzlich lässt sich das Zertifikat an die Quell-IP binden. Über die Critical Option source-address akzeptiert sshd das Zertifikat nur von den angegebenen Adressen, in unserem Fall denen der Runner. Ein abgegriffenes Zertifikat ist damit außerhalb dieses Netzes wertlos. Hierfür ergänzen wir die Rolle um folgende Einträge:

  "default_critical_options": {"source-address": "192.0.2.0/24"},
  "allowed_critical_options": "verify-required"

Der zweite Eintrag wirkt auf den ersten Blick etwas merkwürdig, ist aber wichtig. allowed_critical_options legt fest, welche Critical Options ein Client in seiner Signaturanfrage selbst setzen darf. Eine leere Liste bedeutet dabei nicht „keine“, sondern „alle“.

Daraus folgt zweierlei: source-address darf auf keinen Fall in dieser Liste stehen, sonst könnte der Client den Standardwert in seiner Anfrage überschreiben, etwa mit 0.0.0.0/0. Leer bleiben darf die Liste aber ebenso wenig. Wir müssen also eine andere, harmlose Option finden, die wir als einzige erlauben, sozusagen als Platzhalter. verify-required eignet sich dafür: Die Option verlangt bei FIDO-Schlüsseln eine Bestätigung durch den Benutzer, etwa per PIN, und kann ein Zertifikat damit höchstens weiter einschränken.

Versucht ein Client nun, source-address selbst zu setzen, lehnt Vault die Anfrage mit critical options not on allowed list: [source-address] ab. Den Standardwert aus default_critical_options setzt Vault weiterhin. Ein Login von einer anderen Adresse quittiert sshd mit Refused by certificate options.

Policies und Rollen

Wer was darf, regeln in Vault zwei Dinge: Eine Rolle der JWT-Auth-Methode legt fest, welche Jobs sich anmelden dürfen und welche Policies sie erhalten. Die Policy wiederum bestimmt, was das ausgestellte Vault-Token darf. Für Läufe gegen die Server genügt das Recht, über unsere SSH-Rolle zu signieren:

$ cat ansible-apply.hcl
path "ssh/sign/ansible" {
  capabilities = ["update"]
}
path "kv/data/ansible/*" {
  capabilities = ["read"]
}

$ vault policy write ansible-apply ansible-apply.hcl
Success! Uploaded policy: ansible-apply

Der zweite Block erlaubt zusätzlich das Lesen von Secrets für die Playbooks aus der KV-Engine. Derselbe Login könnte also auch weitere Secrets bereitstellen.

Spannender ist die Rolle, denn hier binden wir den Login an Projekt und Branch:

$ vault write auth/gitlab/role/ansible-apply - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "project_path",
  "bound_audiences": ["https://vault.example.com"],
  "bound_claims_type": "string",
  "bound_claims": {
    "project_path": "infra/ansible",
    "ref": "main",
    "ref_type": "branch",
    "ref_protected": "true"
  },
  "token_policies": ["ansible-apply"],
  "token_ttl": "1h",
  "token_max_ttl": "1h"
}
EOF
Success! Data written to: auth/gitlab/role/ansible-apply

bound_audiences legt fest, für welchen Empfänger ein Token ausgestellt sein muss. Den Wert bestimmt der Job beim Anfordern des ID-Tokens über den Parameter aud unter id_tokens in der .gitlab-ci.yml (siehe Abschnitt „Pipeline“), und GitLab übernimmt ihn als Claim aud in das Token. Üblicherweise ist das die URL des Dienstes, der das Token prüft, hier also https://vault.example.com. Vault akzeptiert nur Tokens, deren aud einem der Werte in bound_audiences entspricht. Ein Token, das ein Job für einen anderen Dienst angefordert hat, lässt sich damit nicht bei Vault verwenden. Die eigentlichen Bedingungen stehen in bound_claims, wobei alle zutreffen müssen. Ein Job aus einem Feature-Branch oder von einem nicht geschützten main scheitert entsprechend beim Login:

* error validating claims: claim "ref" does not match any associated bound claim values
* error validating claims: claim "ref_protected" does not match any associated bound claim values

Gerade ref_protected ist dabei entscheidend. Da GitLab den Wert als String überträgt, steht er auch in der Rolle als "true" in Anführungszeichen. Erst damit greift die Branch-Protection von GitLab, also die Frage, wer auf main pushen und mergen darf, auch für den Zugang zu den Servern. Wer flexiblere Regeln benötigt, kann mit bound_claims_type=glob auch Muster wie release/* verwenden oder weitere Claims wie pipeline_source hinzunehmen.

Auch beim Login lässt sich die Quell-IP einschränken. Mit "token_bound_cidrs": ["192.0.2.0/24"] in der Rolle nimmt Vault Logins nur aus dem Netz der Runner an (andernfalls permission denied), und das ausgestellte Token ist ebenfalls nur von dort aus verwendbar. Steht Vault hinter einem Load Balancer oder Reverse Proxy, muss Vault die echte Client-Adresse kennen, etwa über x_forwarded_for_authorized_addrs im Listener.

In der Praxis hat sich eine Abstufung in mehrere Rollen bewährt. Merge Requests erhalten über eine Rolle ansible-preview, die nur an project_path gebunden ist, lediglich lesenden Zugriff, aber keine SSH-Zertifikate. Die Rollen ansible-check für den Check-Lauf und ansible-apply für das eigentliche Ausrollen sind beide an den geschützten main gebunden und unterscheiden sich nur in ihren Policies.

Dabei sollte man sich eines bewusst machen: Auch der Check-Modus von Ansible meldet sich an den Servern an und nutzt sudo. Einen „nur lesenden“ SSH-Zugang gibt es nicht. Jede Rolle, die ssh/sign/ansible erlaubt, bedeutet faktisch Root-Zugang und muss entsprechend streng gebunden sein.

Server vorbereiten

Auf den Servern sind drei Dinge nötig, die sich sinnvollerweise mit Ansible selbst ausrollen lassen. Zunächst hinterlegen wir den öffentlichen Schlüssel der CA in /etc/ssh/trusted_user_ca_keys und ergänzen die sshd_config:

TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
AllowUsers admin ansible

TrustedUserCAKeys lässt Zertifikate dieser CA zu. Wichtig ist hier zusätzlich AuthorizedPrincipalsFile: Ohne diese Option akzeptiert sshd ein Zertifikat für jeden lokalen Benutzer, dessen Name als Principal im Zertifikat steht. Mit der Option benötigt jeder Benutzer eine eigene Datei mit den für ihn erlaubten Principals. Benutzer ohne diese Datei können sich nicht per Zertifikat anmelden.

Anschließend legen wir den Benutzer ansible als System-User ohne Passwort und ohne authorized_keys an und geben ihm eine Principals-Datei sowie sudo-Rechte:

# cat /etc/ssh/auth_principals/ansible
ansible

# cat /etc/sudoers.d/ansible
ansible ALL=(ALL:ALL) NOPASSWD:ALL

Ein Login als ansible ist damit ausschließlich mit einem gültigen Zertifikat unserer CA möglich. sshd protokolliert dabei Key ID, Seriennummer und CA des Zertifikats (hier mit key_id_format wie oben beschrieben):

Accepted publickey for ansible from 192.0.2.10 port 51945 ssh2: ED25519-CERT SHA256:… ID gitlab-infra/ansible (serial 17651805833285797696) CA ED25519 SHA256:…

Fehlt der Principal, wird das Zertifikat mit Certificate does not contain an authorized principal abgelehnt. Um der Pipeline den Zugang zu einem Server zu entziehen, genügt es also, dort die Principals-Datei zu entfernen.

Einen Haken hat die Sache: Ein neuer Server kennt die CA noch nicht. Der erste Lauf, der CA-Schlüssel, Benutzer und sshd-Konfiguration ausrollt, benötigt daher einen anderen Zugang, beispielsweise einen Admin-Benutzer mit eigenem Schlüssel.

Pipeline

In der .gitlab-ci.yml fassen wir die nötigen Schritte in einem Template zusammen, von dem alle Jobs erben, die gegen die Server laufen:

.ansible-run:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  variables:
    VAULT_ADDR: https://vault.example.com
    ANSIBLE_HOST_KEY_CHECKING: "true"
    ANSIBLE_SSH_ARGS: >-
      -o ControlMaster=auto -o ControlPersist=60s
      -o UserKnownHostsFile=$CI_PROJECT_DIR/ci/known_hosts
      -o StrictHostKeyChecking=yes
  before_script:
    - export VAULT_TOKEN="$(vault write -field=token auth/gitlab/login role="$VAULT_ROLE" jwt="$VAULT_ID_TOKEN")"
    - mkdir -p -m 700 ~/.ssh
    - ssh-keygen -q -t ed25519 -N "" -f ~/.ssh/id_ed25519
    - vault write -field=signed_key ssh/sign/ansible public_key=@"$HOME/.ssh/id_ed25519.pub" > ~/.ssh/id_ed25519-cert.pub

Über id_tokens fordert der Job bei GitLab ein ID-Token mit unserer Audience an, das anschließend in der Variable VAULT_ID_TOKEN bereitsteht. Mit diesem meldet sich der Job bei Vault an, erzeugt ein neues Schlüsselpaar und lässt den öffentlichen Schlüssel signieren. Das Zertifikat legen wir als id_ed25519-cert.pub neben den Schlüssel, wo OpenSSH es ohne weitere Konfiguration findet. Ein Blick in das Zertifikat zeigt das Ergebnis:

$ ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub
/root/.ssh/id_ed25519-cert.pub:
        Type: ssh-ed25519-cert-v01@openssh.com user certificate
        Public key: ED25519-CERT SHA256:BSJp788v7wl/esKJl6i95GRWmCa/fEvKVJCJ/Wbrc/c
        Signing CA: ED25519 SHA256:/6RfVwCFNt+8ljufeokhOH/ZLq+USJn2DarRocTfg2s (using ssh-ed25519)
        Key ID: "vault-token-052269efcf2fef097f7ac28997a8bde464569826bf7c4bca549089fd66eb73f7"
        Serial: 17651805833285797696
        Valid: from 2026-10-19T18:34:41 to 2026-10-19T19:05:11
        Principals:
                ansible
        Critical Options: (none)
        Extensions:
                permit-pty

Ebenso wichtig ist die Gegenrichtung: Auch der Job sollte seinen Gegenstellen nicht blind vertrauen. Die Host-Keys der Server legen wir daher in ci/known_hosts im Repository ab und erzwingen mit StrictHostKeyChecking=yes deren Prüfung. Die TLS-Verbindung zu Vault wird gegen die CA des Vault-Zertifikats geprüft, bei einer internen CA über VAULT_CACERT.

Die eigentlichen Jobs unterscheiden sich dann nur noch durch die Vault-Rolle und ihre rules:

preview:                # Merge Requests: nur Vorschau, kein Serverzugang
  extends: .ansible-run
  variables: {VAULT_ROLE: ansible-preview}
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

check:                  # main und nächtlicher Zeitplan: --check --diff
  extends: .ansible-run
  variables: {VAULT_ROLE: ansible-check}
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:                  # main, manuell gestartet
  extends: .ansible-run
  variables: {VAULT_ROLE: ansible-apply}
  resource_group: production
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

Im preview-Job entfallen Schlüsselerzeugung und Signatur, die Policy würde sie ohnehin verweigern. Die script-Abschnitte mit den eigentlichen ansible-playbook-Aufrufen haben wir der Übersicht halber weggelassen. resource_group sorgt dafür, dass nie zwei Apply-Läufe gleichzeitig gegen die Server arbeiten.

An dieser Stelle lohnt sich ein genauerer Blick auf die Rolle der rules. Sie steuern lediglich, wann ein Job überhaupt generiert wird. Die eigentliche Sicherheitsgrenze liegt in Vault: Ändert jemand in einem Feature-Branch die Pipeline-Definition so, dass ein Job die Rolle ansible-apply anfordert, scheitert der Login an den bound_claims.

Gesamtbild

Fassen wir die einzelnen Teile zusammen, ergibt sich eine Vertrauenskette, in der jedes Glied das vorherige prüft. GitLab signiert das ID-Token, Vault prüft Signatur, Aussteller, Audience, Gültigkeit und Claims. Nur wenn alle Bedingungen zutreffen, erhält der Job ein Vault-Token mit einer Policy, die das Signieren erlaubt. Die SSH-CA signiert den Schlüssel mit festem Principal, begrenzter Laufzeit und eingeschränkten Extensions. sshd schließlich prüft das Zertifikat gegen die hinterlegte CA und die Principals-Datei, AllowUsers und sudoers regeln den Rest.

Wer Zugang erhält, bestimmen damit die Branch-Protection in GitLab und die bound_claims in Vault. Was der Zugang erlaubt, regeln Policy, SSH-Rolle, Principals-Datei und sudo. Wie lange er gilt, legen die TTLs von Vault-Token und Zertifikat fest. Protokolliert wird an drei Stellen: im Audit-Log von Vault, im Job-Log und im Log von sshd auf dem Server.

Fazit

Wie gezeigt, lässt sich der statische Deploy-Schlüssel mit überschaubarem Aufwand durch kurzlebige Zertifikate ersetzen: eine JWT-Auth-Methode, eine SSH-CA mit passender Rolle, einige Policies, wenige Zeilen in der sshd_config und wenige Zeilen in der Pipeline.

Die Vorteile liegen auf der Hand. In GitLab liegt kein langlebiges Secret mehr, das abhandenkommen könnte oder rotiert werden müsste. Jeder Job erzeugt seinen eigenen Schlüssel, und ein Zertifikat ist nach spätestens 30 Minuten wertlos. Vergessene Kopien eines Schlüssels, die noch irgendwo im Einsatz sind, kann es so nicht mehr geben. Der Zugang ist an überprüfbare Eigenschaften des Jobs gebunden: Feature-Branches und Merge Requests erhalten kein Zertifikat, auch nicht mit geänderter Pipeline-Definition. Über das Audit-Log von Vault sowie Seriennummer und Key ID im Log der Server lässt sich jeder Login einem Job zuordnen. Über Rollen und Policies lässt sich zudem fein abstufen, wer nur eine Vorschau erhält und wer ausrollen darf. Und nicht zuletzt lässt sich die gesamte Konfiguration als Code im Repository pflegen.

Leider gibt es aber auch Nachteile. Kann Vault nicht erreicht werden oder ist nicht in der Lage SSH-Keys zu signieren (sealed, etc.), läuft auch die Pipeline nicht. Ein zweiter Zugang für Notfälle bleibt also nötig. Zudem verschiebt sich die Sicherheitsgrenze. Wer Signaturen der SSH-CA erhält, hat Root auf allen Servern. Das gilt damit für jeden, der auf main pushen oder mergen darf. Die Branch-Protection in GitLab und die Policies in Vault werden somit sicherheitskritisch.

Einzelne Zertifikate lassen sich ohne zusätzliche Key Revocation List nicht widerrufen, die kurze Laufzeit begrenzt das Risiko jedoch. Diese Laufzeit begrenzt umgekehrt auch die Dauer eines Laufs, denn sshd prüft das Zertifikat bei jedem neuen Verbindungsaufbau, etwa nach einem Reboot. Darf der Apply-Job auch die Vault-Konfiguration selbst schreiben, ist sein Vault-Token für die Dauer des Jobs faktisch ein Admin-Zugang zu Vault. Hinzu kommen der Bootstrap neuer Server, die Pflege der Host-Keys im Repository und schlicht mehr bewegliche Teile. Fehler äußern sich häufig nur als Permission denied (publickey); hier helfen ssh-keygen -L im Job und das Log von sshd auf dem Server weiter.

Ausbaupotenzial gibt es ebenfalls: Host-Zertifikate aus Vault könnten die gepinnten Host-Keys ersetzen.

Unterm Strich überwiegen für uns die Vorteile deutlich. Statt eines Schlüssels, der irgendwann einmal angelegt wurde und seitdem überall gilt, gibt es einen Zugang, der nachvollziehbar pro Job generiert wird und von selbst wieder verfällt.

Alles, was wir in diesem Beitrag mit Vault gezeigt haben, gilt gleichermaßen für OpenBao, die CLI heißt dort lediglich bao. Der Open-Source-Fork von Vault wird unter dem Dach der OpenSSF der Linux Foundation entwickelt.

Falls Sie Unterstützung beim Einsatz von Ansible, GitLab oder Vault benötigen, steht Ihnen unser Open Source Support Center zur Verfügung.

Kategorien: Ansible Automatisierung
Tags: Ansible Gitlab
KI-Offenlegung:Dieser Inhalt wurde mit KI-Unterstützung geschrieben.

AV

über den Autor

Adrian Vondendriesch

Technischer Leiter

zur Person

Adrian ist seit 2013 Mitarbeiter der credativ GmbH. Als technischer Leiter des Cloud Infrastructure Teams beschäftigt er sich hauptsächlich mit der Planung, Realisierung und Betreuung verteilter Infrastrukturen wie zum Beispiel Kubernetes und Ceph sowie mit der Erarbeitung von Deployment-Strategien. Zuvor war er Teil des Datenbank-Teams bei credativ und war dort unter anderem mit dem Aufbau und der Verwaltung von hochverfügbaren Datenbank-Systemen betreut. Seit 2015 beteiligt er sich aktiv am Debian-Projekt.

Beiträge ansehen


Beitrag teilen: