Zum Inhalt springen

Teleport selbst betreiben mit Terraform und GitOps

Dr. Martin McCaffery 25 min read
Teleport selbst betreiben mit Terraform und GitOps

Einleitung

Teleport ist eine Plattform, die Zugriffe auf Infrastruktur ermöglicht und diese zugleich absichert. Anders als klassische Passwörter, Secrets oder VPNs setzt Teleport auf kryptografische Identitäten und ist konsequent nach Zero-Trust-Prinzipien aufgebaut. Die Plattform ist sowohl als Open-Source-Variante als auch als kommerzielles Produkt verfügbar. Und für alle, die den Betrieb lieber in eigener Regie halten möchten, lassen sich beide Varianten – Open Source wie Enterprise – auch selbst hosten.

In dieser Anleitung zeigen wir Schritt für Schritt, wie sich ein Teleport-Deployment auf Azure aufsetzen lässt. Unser Ziel ist ein Setup, das sich leicht reproduzieren lässt und möglichst wenige manuelle Schritte erfordert. Wir kombinieren dazu Infrastructure as Code (IaC) mit GitOps. Als IaC-Werkzeug greifen wir zu Terraform, für GitOps setzen wir auf ArgoCD.

Voraussetzungen

Bevor Sie mit dieser Anleitung starten, sollten Sie folgende Werkzeuge und Ressourcen zur Hand haben:

  • Azure-Subscription
  • Azure CLI
  • GitHub-Repository
  • helm
  • k9s
  • kubectl
  • Terraform
  • VSCode oder ein Editor Ihrer Wahl

Alle Shell-Befehle und Skripte in dieser Anleitung (sowie im am Ende verlinkten GitHub-Repository) sind auf Bash ausgelegt. Zusätzlich zu den genannten Werkzeugen benötigen Sie einen eigenen Domain-Namen. Die Anleitung funktioniert mit jedem Registrar, bei dem sich NS-Records für die Domain hinterlegen lassen.

Überblick über die Schritte

Auf hoher Ebene laufen die folgenden Schritte ab, um Teleport ans Laufen zu bringen:

  1. Terraform-State-Storage einrichten
  2. Benötigte Infrastruktur anlegen
  3. DNS für die Azure-Infrastruktur konfigurieren
  4. Git-Repository für GitOps vorbereiten
  5. ArgoCD installieren
  6. ArgoCD-Ressourcen aufsetzen
  7. Initialen Benutzer anlegen

Am Ende der Anleitung existieren in Azure folgende Ressourcen:

  • Subscription
    • Resource Group – selfhosted-teleport-mgmt
      • Storage Account – tfstate...
    • Resource Group – selfhosted-teleport
      • Storage Account
      • Key Vault
      • Flexible PostgresDB
      • Azure Kubernetes Service
      • Managed Identities
        • Cert Manager
        • External DNS
        • Teleport
      • Azure DNS Zone

Eine Kostenabschätzung für diese Ressourcen erhalten Sie über den Azure Pricing Calculator.

Jetzt kann es losgehen…

Schritt 1 – Terraform-State-Storage einrichten

Bei nicht trivialen Infrastrukturprojekten mit Terraform ist es dringend zu empfehlen, den State nicht lokal auf der eigenen Maschine, sondern an einem gemeinsam nutzbaren Ort abzulegen. Nur lässt sich dieser State-Storage nicht mit Terraform selbst erzeugen – sonst hätten wir sofort wieder einen State, den wir irgendwo verwalten müssten. Deshalb weichen wir für diesen ersten Schritt auf die Azure CLI aus. Da dieser State nicht durch Terraform verwaltet wird, ist es sinnvoll, ihn strikt von den durch Terraform verwalteten Ressourcen zu trennen. Aus diesem Grund legen wir eine eigene Resource Group für den State an. Das folgende Bash-Skript

  • legt eine neue Resource Group an,
  • erstellt einen neuen Storage Account,
  • erzeugt einen neuen Blob-Container,
  • gibt die Details der erstellten Ressourcen auf der Kommandozeile aus.

Bemerkenswert

  • Der Terraform-State enthält sensible Informationen; sein Inhalt muss unbedingt geschützt werden. Der Storage Account wird daher mit Blob-Level-Encryption angelegt.
  • Storage-Account-Namen müssen in Azure global eindeutig sein und unterliegen relativ strengen Längenbegrenzungen. Das Skript hängt automatisch den aktuellen Zeitstempel in Sekunden an den Namen, um diese Anforderung zu erfüllen.
#!/bin/bash

# Variables
resource_group="selfhosted-teleport-mgmt"
location="germanywestcentral"
storage_account_name="tfstate$(date +%s)" # Ensure uniqueness
container_name="terraform-state"

echo "Creating resource group $resource_group..."
az group create --name $resource_group --location $location

# Step 2: Create the storage account for Terraform state
echo "Creating storage account $storage_account_name..."
az storage account create \
	--name "$storage_account_name" \
	--resource-group $resource_group \
	--location $location \
	--sku Standard_LRS \
	--encryption-services blob

# Step 3: Retrieve the storage account key
account_key=$(az storage account keys list \
	--resource-group $resource_group \
	--account-name "$storage_account_name" \
	--query "[0].value" -o tsv)

# Step 4: Create the blob container for Terraform state
echo "Creating blob container $container_name..."
az storage container create \
	--name $container_name \
	--account-name "$storage_account_name" \
	--account-key "$account_key"

# Output Terraform backend configuration details
echo "Terraform backend configuration:"
echo "Resource Group: $resource_group"
echo "Storage Account Name: $storage_account_name"
echo "Container Name: $container_name"

Legen Sie eine neue Datei namens tf-state-setup.sh an und fügen Sie den obigen Code ein. Führen Sie das Skript anschließend mit bash tf-state-setup.sh aus.

Schritt 2 – Benötigte Infrastruktur anlegen

Nachdem wir nun einen Bucket für unseren Terraform-State haben, können wir die Azure CLI hinter uns lassen und zu Terraform wechseln. Im ersten Schritt legen wir sämtliche Infrastrukturkomponenten an, die wir später benötigen. Dazu zählen:

  • Resource Group
  • Azure Kubernetes Service
  • Azure Key Vault
  • Azure DNS
  • PostgresDB
  • Managed Identities
  • Storage Account

Um die Konfiguration wartbar und lesbar zu halten, teilen wir unsere Terraform-Module auf folgende Dateien auf:

  • backend.tf – Konfiguration des Terraform-State-Backends für das Modul
  • outputs.tf – Alle Outputs des Moduls
  • providers.tf – Alle vom Modul benötigten Provider
  • variables.tf – Alle vom Modul exponierten Variablen
  • main.tf – Alle Ressourcen-Definitionen

Das erste Modul, das wir anlegen, umfasst die Infrastruktur des Teleport-Clusters. Legen Sie dazu einen neuen Ordner namens infra an und erstellen Sie darin die oben genannten Dateien.

backend.tf

Wie bereits erwähnt, ist diese Datei für die Konfiguration des Terraform-State-Backends zuständig. Wichtig ist dabei, dass der State in einer separaten Resource Group liegt – getrennt von den Ressourcen, die Terraform verwaltet. Deshalb geben wir die Resource Group explizit im backend-Block an.

terraform {
  backend "azurerm" {
    resource_group_name  = "selfhosted-teleport-mgmt"
    storage_account_name = "tfstate1738599132"
    container_name       = "terraform-state"
    key                  = "terraform-infra.tfstate"
  }
}

Stellen Sie sicher, dass der storage_account_name mit dem Namen aus der Skript-Ausgabe übereinstimmt.

variables.tf

Die Variables-Datei definiert alle Variablen, die dieser Stack benötigt. Der Einsatz von Variablen erleichtert Anpassungen an der Konfiguration, ohne den eigentlichen Terraform-Code anfassen zu müssen. Das ist insbesondere dann hilfreich, wenn Sie denselben Stack für mehrere Umgebungen verwenden wollen.

variable "subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "tenant_id" {
  description = "Azure tenant ID"
  type        = string
}

variable "domain_name" {
  type        = string
  description = "The domain name you registered (e.g. example.com)."
  default     = "selfhosted.teleport.think-ahead.tech"
}

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group to hold the DNS zone."
  default     = "selfhosted-teleport"
}

variable "location" {
  type        = string
  description = "Location/region for the resource group."
  default     = "germanywestcentral"
}

variable "cluster_name" {
  type        = string
  description = "Name of the selfhosted Teleport cluster."
  default     = "selfhosted-teleport-cluster"
}

providers.tf

Da unsere Infrastruktur in Azure entsteht, müssen wir den passenden Provider einbinden. Zusätzlich nutzen wir den time-Provider, um die Namen bestimmter Ressourcen (etwa die des Storage Accounts) zu randomisieren, sowie den tls-Provider zur Zertifikatserzeugung.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.16.0"
    }

    time = {
      source  = "hashicorp/time"
      version = "0.12.1"
    }

    tls = {
      source  = "hashicorp/tls"
      version = "4.0.6"
    }
  }
}

provider "azurerm" {
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id

  features {}
}

outputs.tf

In dieser Datei definieren wir sämtliche Outputs des Stacks. Besonders erwähnenswert ist der Output azure_dns_name_servers: Diesen Wert müssen Sie später bei Ihrem Registrar in den NS-Records hinterlegen. Die übrigen Outputs kommen im weiteren Verlauf zum Einsatz und erleichtern die Befüllung der ArgoCD-Konfiguration.

output "azure_dns_name_servers" {
  description = "Azure DNS name servers. Configure these in your domain registrar's NS records."
  value       = azurerm_dns_zone.public_dns_zone.name_servers
}

output "cert-manager-identity-id" {
  description = "The ID of the cert-manager identity."
  value       = azurerm_user_assigned_identity.cert_manager_identity.client_id
}

output "teleport-identity-id" {
  description = "The ID of the teleport identity."
  value       = azurerm_user_assigned_identity.teleport_identity.client_id
}

output "external-dns-identity-id" {
  description = "The ID of the external-dns identity."
  value       = azurerm_user_assigned_identity.external_dns_identity.client_id
}

output "storage-account-name" {
  description = "Storage account to be used by teleport."
  value       = azurerm_storage_account.blob_storage.name
}

main.tf

Resource Group

Innerhalb der Datei main.tf werden nun sämtliche Ressourcen des Stacks angelegt. Beginnen wir mit der Resource Group. Fügen Sie folgenden Code in main.tf ein:

data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "teleport_rg" {
  name     = var.resource_group_name
  location = var.location
}
DNS-Zone

Die Resource Group dient als Klammer um alle Ressourcen dieses Stacks. Als Nächstes legen wir die DNS-Zone an. Fügen Sie folgenden Code in main.tf ein:

resource "azurerm_dns_zone" "public_dns_zone" {
  name                = var.domain_name
  resource_group_name = azurerm_resource_group.teleport_rg.name
}

Über die DNS-Zone konfigurieren wir Azure DNS so, dass die Hostnamen der verschiedenen Dienste im Stack aufgelöst werden können.

Kubernetes-Cluster

Danach folgt der Kubernetes-Cluster. Fügen Sie diesen Code in main.tf ein:

resource "azurerm_kubernetes_cluster" "aks" {
  name                = var.cluster_name
  location            = azurerm_resource_group.teleport_rg.location
  resource_group_name = azurerm_resource_group.teleport_rg.name
  dns_prefix          = "${var.cluster_name}-dns"

  default_node_pool {
    name       = "default"
    node_count = 3
    vm_size    = "Standard_B2s"
  }

  network_profile {
    network_plugin    = "azure"
    load_balancer_sku = "basic"
  }

  identity {
    type = "SystemAssigned"
  }

  oidc_issuer_enabled       = true
  workload_identity_enabled = true
}

Der Kubernetes-Cluster nimmt alle Dienste auf, die zum Betrieb von Teleport nötig sind. Beachten Sie dabei folgende Punkte:

  • Mit drei Nodes ermöglichen wir Zero-Downtime-Upgrades. Echte Hochverfügbarkeit ist damit noch nicht gegeben, da alle Nodes in derselben Zone liegen.
  • Wir aktivieren Workload Identity, damit sich Berechtigungen im Cluster über Managed Identities steuern lassen. Die Workloads holen sich ihre Tokens per OIDC, weshalb wir zusätzlich OIDC aktivieren.
ArgoCD

Für ArgoCD verwenden wir GitHub als Single Source of Truth für die Cluster-Konfiguration. Um die SSH-Schlüssel sicher zu speichern, greifen wir auf Azure Key Vault zurück. In dieser Anleitung erzeugen wir den Schlüssel über die entsprechende Terraform-Ressource; alternativ können Sie den Schlüssel auch direkt mit OpenSSH generieren und anschließend importieren. Ein möglicher Nachteil beim Einsatz der Terraform-Ressource: Der Private Key landet im Terraform-State – wer Zugriff auf den State hat, hat damit auch Zugriff auf den Schlüssel. Fügen Sie folgenden Code in main.tf ein. Er

  • erzeugt einen TLS-Schlüssel für ArgoCD,
  • legt einen Key Vault an,
  • speichert ein Secret im Key Vault.
# ArgoCD
## Github Access
resource "tls_private_key" "argo_repo_key" {
  algorithm = "ED25519"
}

resource "azurerm_key_vault" "key_vault" {
  name                = "selfhosted-teleport"
  location            = azurerm_resource_group.teleport_rg.location
  resource_group_name = azurerm_resource_group.teleport_rg.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"

  # Access Policies
  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = data.azurerm_client_config.current.object_id

    secret_permissions = [
      "Set",
      "Get",
      "List",
      "Delete",
      "Purge",
      "Recover",
      "Restore",
    ]
  }
}

resource "azurerm_key_vault_secret" "argo_repo_public_key_secret" {
  name         = "argo-repo-private-key"
  value        = tls_private_key.argo_repo_key.private_key_openssh
  key_vault_id = azurerm_key_vault.key_vault.id
}

resource "azurerm_key_vault_secret" "argo_repo_private_key_secret" {
  name         = "argo-repo-public-key"
  value        = tls_private_key.argo_repo_key.public_key_openssh
  key_vault_id = azurerm_key_vault.key_vault.id
}
Cert-Manager

Da wir unseren Cluster ins Internet öffnen, benötigen wir Zertifikate für die Ingress-Controller. Mit cert-manager lässt sich dieser Prozess vollständig automatisieren. Da cert-manager die DNS-Zone verändern muss, benötigt er die entsprechenden Berechtigungen. Wie bereits erwähnt, setzen wir dafür auf Managed Identities. Fügen Sie folgenden Code in main.tf ein:

# Cert-Manager
## Azure Identity
resource "azurerm_user_assigned_identity" "cert_manager_identity" {
  name                = "${var.cluster_name}-cert-manager"
  resource_group_name = azurerm_resource_group.teleport_rg.name
  location            = azurerm_resource_group.teleport_rg.location
}

resource "azurerm_federated_identity_credential" "cert_manager_identity" {
  name                = azurerm_user_assigned_identity.cert_manager_identity.name
  resource_group_name = azurerm_resource_group.teleport_rg.name
  audience            = ["api://AzureADTokenExchange"]
  issuer              = azurerm_kubernetes_cluster.aks.oidc_issuer_url
  parent_id           = azurerm_user_assigned_identity.cert_manager_identity.id
  subject             = "system:serviceaccount:cert-manager:helm-cert-manager"
}

resource "azurerm_role_assignment" "dns_zone_contributor" {
  scope                = azurerm_dns_zone.public_dns_zone.id
  role_definition_name = "DNS Zone Contributor"
  principal_id         = azurerm_user_assigned_identity.cert_manager_identity.principal_id
}

Achten Sie beim Anlegen dieser Ressourcen in Ihrem eigenen Setup darauf, dass das subject des azurerm_federated_identity_credential mit der ArgoCD-Konfiguration übereinstimmt. Das Format lautet: system:serviceaccount:<KUBERNETES_NAMESPACE>:<ARGOCD_APPLICATION_METADATA_NAME>.

ExternalDNS

Zum Anlegen der eigentlichen Hostnamen in der DNS-Zone verwenden wir ExternalDNS. ExternalDNS inspiziert automatisch die im Cluster deployten Kubernetes-Ressourcen und exponiert sie nach außen. Ähnlich wie cert-manager muss ExternalDNS mit AzureDNS interagieren und benötigt dafür entsprechende Berechtigungen. Fügen Sie folgenden Code in main.tf ein:

# ExternalDNS
## Azure Identity
resource "azurerm_user_assigned_identity" "external_dns_identity" {
  name                = "${var.cluster_name}-external-dns"
  resource_group_name = azurerm_resource_group.teleport_rg.name
  location            = azurerm_resource_group.teleport_rg.location
}

resource "azurerm_federated_identity_credential" "external_dns_identity" {
  name                = azurerm_user_assigned_identity.external_dns_identity.name
  resource_group_name = azurerm_resource_group.teleport_rg.name
  audience            = ["api://AzureADTokenExchange"]
  issuer              = azurerm_kubernetes_cluster.aks.oidc_issuer_url
  parent_id           = azurerm_user_assigned_identity.external_dns_identity.id
  subject             = "system:serviceaccount:external-dns:helm-external-dns"
}

resource "azurerm_role_assignment" "external_dns_dns_zone_contributor" {
  scope                = azurerm_dns_zone.public_dns_zone.id
  role_definition_name = "DNS Zone Contributor"
  principal_id         = azurerm_user_assigned_identity.external_dns_identity.principal_id
}

resource "azurerm_role_assignment" "external_dns_resource_group_reader" {
  scope                = azurerm_resource_group.teleport_rg.id
  role_definition_name = "Reader"
  principal_id         = azurerm_user_assigned_identity.external_dns_identity.principal_id
}

Wie Sie sehen, legen wir für ExternalDNS gegenüber cert-manager eine zusätzliche Rollenzuweisung an. Diese ist notwendig, damit ExternalDNS sämtliche Netzwerke innerhalb der Resource Group inspizieren kann. Es werden dabei keinerlei Änderungen vorgenommen, weshalb die Reader-Rolle ausreicht. Auch hier gilt derselbe Hinweis wie bei cert-manager, was das subject des azurerm_federated_identity_credential betrifft. Wer sich strikt an diese Anleitung hält, sollte keine Probleme bekommen – bei eigenen Anpassungen ist jedoch Vorsicht geboten.

Teleport

Nun sind wir bereit, die Ressourcen für den Teleport-Cluster anzulegen. Benötigt werden:

  • eine Managed Identity, die derselben Logik folgt wie die Identities für ExternalDNS und cert-manager. Sie dient hier der Verbindung zur Datenbank und zum Storage Account.
  • eine Datenbank; in dieser Anleitung setzen wir auf ein hochverfügbares Setup und verwenden daher Azure Database for PostgreSQL Flexible Server.
  • ein Storage Account, in dem Teleport die Session Recordings ablegt.

Um all diese Ressourcen einzurichten, ergänzen Sie folgenden Code in Ihrer main.tf und ersetzen Sie die Werte im ersten azurerm_postgresql_flexible_server_active_directory_administrator-Block durch eine echte Gruppe aus Ihrer Entra ID.

# Teleport
resource "azurerm_user_assigned_identity" "teleport_identity" {
  name                = "${var.cluster_name}-teleport"
  resource_group_name = azurerm_resource_group.teleport_rg.name
  location            = azurerm_resource_group.teleport_rg.location
}

resource "azurerm_federated_identity_credential" "teleport_identity" {
  name                = azurerm_user_assigned_identity.teleport_identity.name
  resource_group_name = azurerm_resource_group.teleport_rg.name
  audience            = ["api://AzureADTokenExchange"]
  issuer              = azurerm_kubernetes_cluster.aks.oidc_issuer_url
  parent_id           = azurerm_user_assigned_identity.teleport_identity.id
  subject             = "system:serviceaccount:teleport:helm-teleport"
}

## Database
resource "azurerm_postgresql_flexible_server" "teleport" {
  name                = "self-hosted-teleport-db"
  location            = azurerm_resource_group.teleport_rg.location
  resource_group_name = azurerm_resource_group.teleport_rg.name
  zone                = "2"
  version             = "15"

  sku_name = "GP_Standard_D2s_v3"

  public_network_access_enabled = true

  authentication {
    active_directory_auth_enabled = true
    password_auth_enabled         = false
  }

  high_availability {
    mode = "SameZone"
  }
}

resource "azurerm_postgresql_flexible_server_configuration" "wal_level" {
  name      = "wal_level"
  server_id = azurerm_postgresql_flexible_server.teleport.id
  value     = "logical"
}

resource "azurerm_postgresql_flexible_server_database" "teleport" {
  name      = "teleport"
  server_id = azurerm_postgresql_flexible_server.teleport.id
  collation = "en_US.utf8"
  charset   = "utf8"
}

resource "azurerm_postgresql_flexible_server_firewall_rule" "teleport" {
  name             = "AllowAccessFromAzure"
  server_id        = azurerm_postgresql_flexible_server.teleport.id
  start_ip_address = "0.0.0.0" # make this more restrictive in production!
  end_ip_address   = "0.0.0.0" # make this more restrictive in production!
}

resource "azurerm_postgresql_flexible_server_active_directory_administrator" "teleport" {
  server_name         = azurerm_postgresql_flexible_server.teleport.name
  resource_group_name = azurerm_resource_group.teleport_rg.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  object_id           = "ffffffff-ffff-ffff-ffff-ffffffffffff" # replace this with an EntraID group id
  principal_name      = "access" # replace this with an EntraID group name
  principal_type      = "Group"
}

resource "azurerm_postgresql_flexible_server_active_directory_administrator" "teleport_pg_admin" {
  server_name         = azurerm_postgresql_flexible_server.teleport.name
  resource_group_name = azurerm_resource_group.teleport_rg.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  object_id           = azurerm_user_assigned_identity.teleport_identity.principal_id
  principal_name      = azurerm_user_assigned_identity.teleport_identity.name
  principal_type      = "ServicePrincipal"
}

resource "azurerm_role_assignment" "teleport_pg_admin" {
  scope                = azurerm_postgresql_flexible_server.teleport.id
  role_definition_name = "Contributor"
  principal_id         = azurerm_user_assigned_identity.teleport_identity.principal_id
}

## Storage Account
resource "time_static" "timestamp" {
  triggers = {
    generate_time = "once"
  }
}

resource "azurerm_storage_account" "blob_storage" {
  name                     = "teleport${time_static.timestamp.unix}"
  resource_group_name      = azurerm_resource_group.teleport_rg.name
  location                 = azurerm_resource_group.teleport_rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  public_network_access_enabled = false
}

resource "azurerm_role_assignment" "blob_data_owner" {
  scope                = azurerm_storage_account.blob_storage.id
  role_definition_name = "Storage Blob Data Owner"
  principal_id         = azurerm_user_assigned_identity.teleport_identity.principal_id
}

Sollte Ihnen die main.tf zu groß werden, ist es völlig legitim, den Inhalt auf mehrere Dateien aufzuteilen. Häufig wird empfohlen, Dateien innerhalb eines Stacks nach Ressourcentyp aufzuteilen (etwa eine Datei für Netzwerk-Ressourcen, eine weitere für Benutzer). Ich empfehle dagegen, die Aufteilung an den Ressourcen zu orientieren, die sich gemeinsam ändern. Für dieses Setup wäre eine Aufteilung der main.tf in folgende Dateien denkbar:

  • resource-group.tf
  • dns.tf
  • kubernetes.tf
  • argo-cd.tf
  • cert-manager.tf
  • external-dns.tf
  • teleport.tf

Dateien anwenden

Nachdem Sie sämtliche Inhalte in die Dateien eingefügt haben, sollten Sie folgende Struktur vor sich haben:

repository-root/
|-- tf/
|---- infra/
|------ backend.tf
|------ outputs.tf
|------ providers.tf
|------ variables.tf
|------ main.tf

Öffnen Sie Ihre Shell und wechseln Sie in das Verzeichnis infra. Zwei unserer Variablen sind subscription_id und tenant_id. Diese Werte lassen sich über die Azure CLI ermitteln und in terraform.tfvars ablegen:

subscription_id=$(az account show --query id -o tsv)
tenant_id=$(az account show --query tenantId -o tsv)
echo "subscription_id = \"$subscription_id\"" > terraform.tfvars
echo "tenant_id = \"$tenant_id\"" >> terraform.tfvars

Da die Azure CLI nicht gerade als schnell bekannt ist, kann die Ausführung dieser Kommandos etwas dauern. Wenn Sie den Terraform-Stack zum ersten Mal ausführen, müssen Sie ihn zunächst initialisieren:

terraform init

Damit werden alle in providers.tf deklarierten Provider heruntergeladen und die Verbindung zum State-Bucket in Azure eingerichtet. Nach erfolgreicher Initialisierung sind wir bereit, die Ressourcen anzulegen. Führen Sie dazu

terraform apply

aus. Terraform inspiziert daraufhin alle Ressourcen in Azure und ermittelt die notwendigen Änderungen. Die geplanten Änderungen werden aufgelistet – Sie müssen den sogenannten Plan explizit bestätigen. Nach der Bestätigung führt Terraform die Änderungen in Azure aus. Sobald alles angewendet wurde, erscheinen die Outputs auf der Konsole.

Mit dem Cluster verbinden

Schritt 3 – DNS für Azure-Infrastruktur konfigurieren

In den Outputs des vorherigen Schritts finden Sie den Output azure_dns_name_servers. Er enthält die Nameserver der Azure-DNS-Zone. Um die DNS-Zone in Betrieb zu nehmen, tragen Sie diese Nameserver als NS-Records bei Ihrem DNS-Provider ein. Anschließend kann es einige Zeit dauern, bis sich die Änderungen propagiert haben (bis zu 24 Stunden). Ob die NS-Records bereits verteilt sind, lässt sich bequem über das Dig-Online-Tool unter https://www.digwebinterface.com prüfen. Wählen Sie dort folgende Optionen:

  • Type: NS
  • Hostnames: <IHRE DOMAIN>
  • Options: Trace
  • Nameservers: Resolver - Default

Am Ende der Dig-Ausgabe sollten dann die NS-Records erscheinen, die den in Azure DNS hinterlegten Nameservern entsprechen.

Schritt 4 – Git-Repository für GitOps konfigurieren

Sobald – oder parallel zur – Propagation der DNS-Zone können wir das Git-Repository für GitOps vorbereiten. Legen Sie dazu zunächst ein neues Repository auf GitHub an. Um uns das Leben zu erleichtern, verwenden wir das Deploy-Key-Feature von GitHub. So kann ArgoCD Änderungen aus dem Git-Repository ziehen, ohne einen Personal Access Token nutzen zu müssen. Um den Deploy Key anzulegen, benötigen wir den öffentlichen Schlüssel aus dem im vorherigen Schritt erzeugten Azure Key Vault. Öffnen Sie dazu das Azure Portal, navigieren Sie zur Resource Group und wählen Sie den Key Vault aus. Öffnen Sie im Key Vault die Secrets und wählen Sie den Public Key aus. Mit dem Wert des öffentlichen Schlüssels wechseln Sie zu Ihrem GitHub-Repository und rufen die Einstellungen auf. Im Bereich Security wählen Sie „Deploy keys”. Legen Sie einen neuen Eintrag mit dem Public Key und einem Namen Ihrer Wahl an – persönlich verwende ich den Namen GitOps.

Schritt 5 – ArgoCD installieren

Damit haben wir alles vorbereitet, was für den Einsatz von ArgoCD nötig ist. Für die Installation greifen wir auf das Helm-Chart von https://argoproj.github.io/argo-helm zurück. Es handelt sich dabei um ein von der Community gepflegtes Helm-Chart, das in unseren Tests aber ohne Auffälligkeiten funktioniert hat. Innerhalb unseres Projektordners legen wir für die ArgoCD-Installation einen eigenen Stack an – mit denselben Dateien wie der vorherige Terraform-Stack. Damit ergibt sich innerhalb des Projektordners folgende Struktur:

repository-root/
|-- tf/
|---- infra/
|------ backend.tf
|------ ...
|---- k8s/
|------ backend.tf
|------ outputs.tf
|------ providers.tf
|------ variables.tf
|------ main.tf

Die backend.tf im k8s-Stack ist identisch mit derjenigen im infra-Stack; wir kopieren sie einfach herüber. Da wir aus diesem Stack keine Outputs benötigen, kann die outputs.tf leer bleiben – wir legen sie dennoch an, um sie für zukünftige Erweiterungen bereitzuhalten.

variables.tf

In die variables.tf des k8s-Stacks fügen wir folgende Variablen ein:

variable "subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "tenant_id" {
  description = "Azure tenant ID"
  type        = string
}

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group to hold the DNS zone."
  default     = "selfhosted-teleport"
}

variable "cluster_name" {
  type        = string
  description = "Name of the selfhosted Teleport cluster."
  default     = "selfhosted-teleport-cluster"
}

Wichtig: Der Name der Resource Group muss mit dem übereinstimmen, der im vorherigen Schritt angelegt wurde.

providers.tf

Da wir jetzt Kubernetes-Ressourcen mit Terraform anlegen und zusätzlich mit Helm arbeiten wollen, müssen wir die entsprechenden Provider einbinden. Fügen Sie folgenden Inhalt in die providers.tf im k8s-Stack ein:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.16.0"
    }

    helm = {
      source  = "hashicorp/helm"
      version = "2.17.0"
    }

    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.35.1"
    }

    tls = {
      source  = "hashicorp/tls"
      version = "4.0.6"
    }
  }
}

provider "azurerm" {
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id

  features {}
}

provider "kubernetes" {
  host                   = data.azurerm_kubernetes_cluster.aks.kube_config.0.host
  client_certificate     = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_certificate)
  client_key             = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_key)
  cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.cluster_ca_certificate)

}

provider "helm" {
  kubernetes {
    host                   = data.azurerm_kubernetes_cluster.aks.kube_config.0.host
    client_certificate     = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_certificate)
    client_key             = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_key)
    cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.cluster_ca_certificate)
  }
}

Wir verwenden hier data-Blöcke, um Informationen über den Kubernetes-Cluster aus dem Terraform-State zu ziehen. Der data-Block selbst wird in der main.tf des k8s-Stacks definiert.

main.tf

In der main.tf installieren wir schlicht ein Helm-Chart in den Kubernetes-Cluster. Das macht es sehr einfach – wir fügen lediglich folgenden Code ein:

data "azurerm_resource_group" "teleport_rg" {
  name = var.resource_group_name
}

data "azurerm_kubernetes_cluster" "aks" {
  name                = var.cluster_name
  resource_group_name = data.azurerm_resource_group.teleport_rg.name
}

## Helm Chart
resource "helm_release" "argocd" {
  name       = "argocd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  namespace  = "argocd"

  create_namespace = true

  depends_on = [
    data.azurerm_kubernetes_cluster.aks
  ]
}

Damit ist der Stack fertig, um in unserem Cluster angewendet zu werden. Gehen Sie dabei genauso vor wie beim infra-Stack:

  • Wechseln Sie in Ihrer Shell in den k8s-Stack.
  • Führen Sie terraform init aus.
  • Legen Sie die terraform.tfvars mit den Werten aus dem vorherigen Schritt an.
  • Führen Sie terraform apply aus, prüfen Sie den Plan und bestätigen Sie die Änderungen.
  • Warten Sie, bis die Änderungen angewendet sind.

(Optional) Mit ArgoCD verbinden

Falls Sie die per GitOps ausgerollten Änderungen visuell nachverfolgen möchten, können Sie die ArgoCD-UI öffnen. Da es für ArgoCD zunächst noch keinen Ingress gibt, greifen wir per Port-Forwarding darauf zu. Dazu müssen wir uns mit dem Cluster verbinden – am einfachsten geht das, indem Sie den Cluster im Azure Portal lokalisieren und folgendes Kommando ausführen:

az aks get-credentials --resource-group selfhosted-teleport --name selfhosted-teleport-cluster --overwrite-existing

Danach können Sie mit k9s auf den Cluster zugreifen. Sobald die Verbindung zum Cluster steht, extrahieren wir das ArgoCD-Admin-Passwort aus dem Secret. Achten Sie darauf, dieses Passwort niemandem außerhalb Ihres Kreises zugänglich zu machen.

kubectl --context=selfhosted-teleport-cluster --namespace argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 --decode

Nachdem wir das Passwort ermittelt haben, leiten wir den entsprechenden Port auf unsere lokale Maschine weiter. Zum Glück reicht dafür ein einfaches kubectl port-forward. Führen Sie folgendes Kommando aus, um den Port auf Ihre lokale Maschine weiterzuleiten:

kubectl --context=selfhosted-teleport-cluster --namespace argocd port-forward service/argocd-server 8080:443

Nun können Sie die ArgoCD-UI im Browser unter http://localhost:8080 öffnen. Nach der Anmeldung mit dem Benutzernamen admin landen Sie auf dem ArgoCD-Dashboard. Aktuell sollte es noch leer sein – im nächsten Schritt richten wir unsere Ressourcen per GitOps ein.

Schritt 6 – ArgoCD-Ressourcen aufsetzen

Damit dieses Deployment wartbar und einfach aktualisierbar bleibt, setzen wir auf GitOps. Das bedeutet: GitHub wird zur Single Source of Truth für die Cluster-Konfiguration. Folgende Ressourcen werden per GitOps verwaltet:

  • cert-manager
  • external-dns
  • Teleport
  • nginx-controller

Jede dieser Komponenten wird in ArgoCD als Application bezeichnet. Da wir ArgoCD über Helm aus Terraform installiert haben, müssten wir Applications zunächst ebenfalls per Terraform konfigurieren. Auf Dauer wäre das unpraktisch – jede Änderung an der ArgoCD-Konfiguration würde eine Anpassung am Terraform-Code erfordern. Idealerweise wollen wir die ArgoCD-Konfiguration ausschließlich über Git pflegen und ArgoCD den Rest erledigen lassen. Deshalb verwenden wir das App-of-Apps-Muster. Mit diesem Muster legen wir in Terraform eine einzige, neue Application an, die dann für den Rollout aller weiteren Applications zuständig ist.

App of Apps anlegen

Für die Seed-Application des App-of-Apps-Musters legen wir einen neuen Terraform-Stack namens argocd an. Damit ergibt sich folgende Projektstruktur:

repository-root/
|-- tf/
|---- argocd/
|------ backend.tf
|------ outputs.tf
|------ providers.tf
|------ variables.tf
|------ main.tf
|---- infra/
|------ backend.tf
|------ ...
|---- k8s/
|------ backend.tf
|------ ...

Wie schon beim k8s-Stack unterscheidet sich die backend.tf kaum von der im infra-Stack. Wir passen lediglich den key auf terraform-argocd.tfstate an. Zusätzlich legen wir eine output.tf an, die vorerst leer bleibt.

providers.tf

Wie im k8s-Stack legen wir Kubernetes-Ressourcen per Terraform an, diesmal aber ohne Helm-Interaktion. Fügen Sie folgenden Inhalt in die providers.tf im argocd-Stack ein:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.16.0"
    }

    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.35.1"
    }

    tls = {
      source  = "hashicorp/tls"
      version = "4.0.6"
    }
  }
}

provider "azurerm" {
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id

  features {}
}

provider "kubernetes" {
  host                   = data.azurerm_kubernetes_cluster.aks.kube_config.0.host
  client_certificate     = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_certificate)
  client_key             = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.client_key)
  cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.aks.kube_config.0.cluster_ca_certificate)
}

variables.tf

Um auf die bereits vorhandene Infrastruktur zuzugreifen, arbeiten wir mit data-Ressourcen. Da sich Ressourcennamen ändern können, übergeben wir sie als Variablen. Fügen Sie folgenden Inhalt in die variables.tf im argocd-Stack ein:

variable "subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "tenant_id" {
  description = "Azure tenant ID"
  type        = string
}

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group to hold the DNS zone."
  default     = "selfhosted-teleport"
}

variable "cluster_name" {
  type        = string
  description = "Name of the selfhosted Teleport cluster."
  default     = "selfhosted-teleport-cluster"
}

main.tf

In der main.tf müssen wir

  • ein Kubernetes-Secret anlegen, mit dem ArgoCD auf das GitHub-Repository zugreift. Dieses Secret enthält den Private Key des GitHub-Repositorys, der im Azure Key Vault liegt. Um auf ihn zugreifen zu können, definieren wir einen data-Block für die entsprechende Ressource.
  • die App of Apps anlegen und auf ein konkretes Verzeichnis in unserem GitHub-Repository verweisen lassen.

Fügen Sie dazu folgenden Inhalt in die main.tf ein:

data "azurerm_resource_group" "teleport_rg" {
  name = var.resource_group_name
}

data "azurerm_kubernetes_cluster" "aks" {
  name                = var.cluster_name
  resource_group_name = data.azurerm_resource_group.teleport_rg.name
}

data "azurerm_key_vault" "key_vault" {
  name                = "selfhosted-teleport"
  resource_group_name = data.azurerm_resource_group.teleport_rg.name
}

data "azurerm_key_vault_secret" "argo_repo_private_key_secret" {
  name         = "argo-repo-private-key"
  key_vault_id = data.azurerm_key_vault.key_vault.id
}

## Repository Secret
resource "kubernetes_secret" "argo_repo_secret" {
  metadata {
    name      = "teleport-self-hosted-azure"
    namespace = "argocd"
    labels = {
      "argocd.argoproj.io/secret-type" = "repository"
    }
  }

  data = {
    type          = "git"
    url           = "<YOUR REPO SSH CLONE URL>"
    sshPrivateKey = data.azurerm_key_vault_secret.argo_repo_private_key_secret.value
  }

  depends_on = [
    data.azurerm_kubernetes_cluster.aks,
  ]
}

## ArgoCD App of Apps
resource "kubernetes_manifest" "argocd_application" {
  manifest = {
    apiVersion = "argoproj.io/v1alpha1"
    kind       = "Application"
    metadata = {
      name      = "self-hosted-teleport"
      namespace = "argocd"
      finalizers = [
        "resources-finalizer.argocd.argoproj.io"
      ]
    }
    spec = {
      destination = {
        namespace = "argocd"
        server    = "https://kubernetes.default.svc"
      }
      project = "default"
      source = {
        path           = "argocd/infra/self-hosted-teleport-root"
        repoURL        = "<YOUR REPO SSH CLONE URL>"
        targetRevision = "HEAD"
      }
      syncPolicy = {
        automated = {
          prune    = true
          selfHeal = true
        }
        syncOptions = [
          "PruneLast=true",
          "RespectIgnoreDifferences=true",
          "ApplyOutOfSyncOnly=true",
        ]
      }
    }
  }

  depends_on = [
    data.azurerm_kubernetes_cluster.aks,
  ]
}

Nach terraform apply sollte im ArgoCD-Dashboard eine neue Application auftauchen. Zunächst ist diese Application leer und rollt noch nichts aus. Um das zu ändern, müssen wir das Repository mit den Application-Manifesten befüllen.

GitHub-Repository befüllen

Im angelegten GitHub-Repository benötigen wir folgende Struktur:

repository-root/
|-- argocd/
|---- infra/
|------ helm-cert-manager/
|-------- templates/
|---------- cluster-issuer-production.yaml
|---------- cluster-issuer-staging.yaml
|-------- Chart.yaml
|-------- values.yaml
|------ helm-external-dns/
|-------- Chart.yaml
|-------- values.yaml
|------ helm-nginx-controller/
|-------- Chart.yaml
|-------- values.yaml
|------ helm-teleport/
|-------- Chart.yaml
|-------- values.yaml
|------ self-hosted-teleport-root/
|-------- templates/
|---------- helm-cert-manager.yaml
|---------- helm-external-dns.yaml
|---------- helm-nginx-controller.yaml
|---------- helm-teleport.yaml
|-------- Chart.yaml
|-------- values.yaml
|-- tf/
|---- ...

Jedes der Unterverzeichnisse in argocd/infra definiert eine Application, die von ArgoCD verwaltet wird. Die Application self-hosted-teleport-root ist die Wurzel-Application und sorgt dafür, dass alle anderen Applications ausgerollt werden. Sämtliche ArgoCD-Application-Konfigurationen finden Sie im begleitenden GitHub-Repository. Statt jede Datei im Detail zu besprechen, konzentrieren wir uns auf einige besonders erwähnenswerte Aspekte.

Umgang mit OIDC Subjects

Wir hatten bereits darauf hingewiesen, dass das Subject der Federated Identities zur ArgoCD-Konfiguration passen muss. Diese Konfiguration wird über die Application-Konfiguration in der Root-Application gesteuert. Als Beispiel schauen wir uns die Application helm-cert-manager an. In der Terraform-Ressource ist das Subject auf subject = "system:serviceaccount:cert-manager:helm-cert-manager" gesetzt; das Format lautet system:serviceaccount:<KUBERNETES_NAMESPACE>:<ARGOCD_APPLICATION_METADATA_NAME>. Die zugehörige ArgoCD-Konfiguration lautet:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
    name: helm-cert-manager
    namespace: argocd
    finalizers:
        - resources-finalizer.argocd.argoproj.io
spec:
    destination:
        namespace: cert-manager
        server: https://kubernetes.default.svc
    project: default
    source:
        path: argocd/infra/helm-cert-manager
        repoURL: git@github.com:think-ahead-technologies/blog-self-hosted-teleport.git
        targetRevision: HEAD
    syncPolicy:
        automated:
            prune: true
            selfHeal: true
        syncOptions:
            - CreateNamespace=true

Beachten Sie spec.destination.namespace – das ist der Namespace, in den ArgoCD die Application deployt. Er muss mit dem Namespace aus dem Subject übereinstimmen. Ebenso wichtig ist metadata.name: der Name, den ArgoCD für die Application setzt. Genau dieser Name wird für die Authentifizierung verwendet. Dasselbe Muster findet sich in den Konfigurationen von helm-external-dns und helm-teleport wieder.

Client-IDs und Storage Account

Die outputs.tf des infra-Stacks enthielt diverse Client-IDs. Diese müssen an den passenden Stellen eingetragen werden:

  • cert-manager-Identity: cluster-issuer-production.yaml und cluster-issuer.yaml
  • external-dns: values.yaml unter argocd/infra/helm-external-dns
  • Teleport: values.yaml unter argocd/infra/helm-teleport

Zusätzlich muss der korrekte Storage-Account-Name für Teleport gesetzt werden. Diese Einstellung erfolgt in der values.yaml unter argocd/infra/helm-teleport.

Applications ausrollen

Damit ist alles bereit, um die Applications auszurollen. Committen Sie die Dateien in das GitHub-Repository und pushen Sie sie in den main-Branch. ArgoCD greift sich die Änderungen von dort und rollt sie im Cluster aus. Den Fortschritt der Deployments können Sie im ArgoCD-Dashboard beobachten. Beachten Sie, dass die Application für cert-manager unter Umständen als out-of-sync angezeigt wird – dabei handelt es sich lediglich um einen Health-Check, der nicht korrekt behandelt wird. Beachten Sie außerdem, dass Teleport die Zertifikate über Let’s Encrypt ausstellt; es kann etwas dauern, bis diese bereitstehen. Erst wenn die Zertifikate ausgestellt sind, wird die Teleport-Application erfolgreich deployed. Erschrecken Sie also nicht, wenn das gesamte Deployment 15 bis 20 Minuten in Anspruch nimmt. Sobald die Teleport-Application im ArgoCD-Dashboard als synced angezeigt wird, ist alles bereit.

Schritt 7 – Initialen Benutzer anlegen

Nachdem der Cluster läuft, legen wir den initialen Benutzer für Teleport an. Dafür verwenden wir das Kommandozeilen-Tool kubectl. Führen Sie einfach den folgenden Befehl aus, um den initialen Benutzer zu erstellen. Als Rückgabe erhalten Sie eine URL, über die sich das initiale Passwort setzen lässt. Beachten Sie: Diese URL ist nur eine Stunde lang gültig. Und es versteht sich von selbst, dass diese URL nicht öffentlich zugänglich sein sollte – wer sie kennt, kann den initialen Benutzer anlegen und das Passwort festlegen.

kubectl --context=selfhosted-teleport-cluster --namespace teleport exec deploy/helm-teleport-auth -- tctl users add initial.admin --roles=access,editor

Herzlichen Glückwunsch – Sie haben Teleport erfolgreich mit Terraform und GitOps auf Azure aufgesetzt. Sollten Sie beim Nachvollziehen dieser Anleitung auf Probleme stoßen, werfen Sie einen Blick in das begleitende GitHub-Repository mit dem vollständigen Code: https://github.com/think-ahead-technologies/blog-self-hosted-teleport.


Hinweis: Think Ahead Technologies ist offizieller Teleport Reseller und Support Partner. Dieser Artikel spiegelt unsere praktischen Erfahrungen aus Teleport-Deployments für Kunden und interne Projekte wider.