Tech

Let's Encrypt automatiseren met Certbot en TransIP in Docker

Wil je je SSL-certificaten automatisch updaten en beheren in Docker met Certbot, met je domein gehost bij TransIP? Dan hoef je niet verder te zoeken!

9 min lezen

Foto van Jakub Żerdzicki / Unsplash

SSL-certificaten beheren voor domeinen die bij TransIP gehost worden, kan zonder automatisering een gedoe zijn. TransIP biedt gelukkig een degelijke API voor DNS-beheer, maar om die naadloos te integreren met Certbot voor dns-01-validatie is een eigen oplossing nodig. Omdat ik daar geen kant-en-klaar script voor kon vinden, heb ik er zelf één gemaakt dat veilig de authenticatie, DNS-updates en het aanmaken van certificaten regelt.

De volledige API-documentatie vind je hier:

TransIP API

Deze handleiding legt de complete setup met Docker uit, inclusief hoe je het API-keypair genereert in de webinterface van TransIP en gebruikt voor geautomatiseerd certificaatbeheer.

Hoe de authenticatie bij TransIP werkt

TransIP gebruikt een RSA-keypair voor API-authenticatie. Zo stel je dat in:

  1. Genereer het keypair in TransIP:
    • Log in op je TransIP-controlepaneel.
    • Ga naar API-instellingen onder je account en schakel API-toegang in.
    • Genereer een nieuw API-keypair.
    • Download/sla de private key (transip_private_key.pem) veilig op en maak er een secret van voor gebruik in je Docker-setup.
docker secret create transip_private_key transip_private_key.pem
  1. Houd de private key veilig:
    • Bewaar de private key op een veilige plek nadat je er een secret van hebt gemaakt.
    • Deze private key is nodig om requests naar de API van TransIP te signen.
  2. Hoe het werkt:
    • Het script gebruikt de private key om een authenticatie-request te signen.
    • De API van TransIP antwoordt met een token met korte geldigheid (standaard 10 minuten), die vervolgens gebruikt wordt om de DNS-records te beheren.
    • Voor het gemak maakt het script bij elke run een nieuwe token aan. Dat maakte het robuuster dan controleren op een actieve token en die hergebruiken, wat het hele script minder betrouwbaar en omslachtiger maakte.

Wat deze setup gaat doen

  1. Automatiseert SSL-certificaten:
    • Automatiseert het aanmaken en vernieuwen van wildcard- en domeinspecifieke certificaten.
  2. Verwerkt DNS-challenges:
    • Beheert dns-01-challenges door DNS TXT-records aan te maken en te verwijderen via de TransIP API.
  3. Naadloze integratie:
    • Alles zit in een Docker container voor draagbaarheid en eenvoud.
    • Je kunt in dit script domeinen verwijderen of toevoegen bij het -d-gedeelte. Ik heb de voorbeelden erin laten staan voor een betere uitleg.

We pipen een add- of clean-commando naar het bash-script om de acties uit te voeren die in het script staan. We gebruiken hier dus de –manual-auth-hook en de manual-cleanup-hook. Zo wordt bijvoorbeeld gegarandeerd dat een fout in het add-gedeelte alsnog netjes wordt opgeruimd.

Je kunt ook een –force aan dit script toevoegen, maar dan ben je snel door je pogingen bij Letsencrypt heen, want ze staan maar een paar calls per 24 uur toe. Wel zorgt dit ervoor dat de certificaten altijd vernieuwd worden als je iets wilt testen of verifiëren.

version: "3.8"

services:
  certbot:
    image: certbot/certbot:latest
    container_name: certbot-transip
    secrets:
      - transip_private_key  # Reference the secret you created earlier
    # adjust these volumes to point to the right .pem and script. right now this dockerfile expects the files to be in the same folder as the docker-certbot-transip.yml
    volumes:
      - "./certbot-transip.sh:/usr/local/bin/certbot-transip.sh:ro"
      - "./letsencrypt:/etc/letsencrypt"
    environment:
    #These Variables are going to be piped in our bash script.
      LOGIN_NAME: "your-transip-login"        # Replace with your TransIP login name
      PRIVATE_KEY_PATH: "/etc/transip_private_key.pem"
      DOMAIN: "example.com"                  # Replace with your domain
      TTL: "60"                              # Optional: Time-to-live for DNS challenge
      CERTBOT_EMAIL: "you@example.com"       # Replace with your valid email as requested by letsencrypt.
    entrypoint:
      - sh
      - -c
      - |
        apk update && apk add --no-cache bash curl jq bind-tools && \
        chmod +x /usr/local/bin/certbot-transip.sh && \
        certbot certonly --manual --preferred-challenges dns \
          --manual-auth-hook "/usr/local/bin/certbot-transip.sh add" \
          --manual-cleanup-hook "/usr/local/bin/certbot-transip.sh clean" \
          --cert-name "$DOMAIN" \
          -d "*.$DOMAIN" \
          -d "example2.$DOMAIN" \
          -d "example3.$DOMAIN" \
          --expand \
          --non-interactive --agree-tos \
          --email "$CERTBOT_EMAIL"

secrets:
  transip_private_key:
    external: true  # Secret must already exist in Docker

Het Bash-script

Dit is het “geavanceerde” deel dat zijn magie doet en alles met TransIP laat werken. Ik heb het ook een stuk robuuster gemaakt dan standaard. Dit zou dus hopelijk helpen om een stabiele en goed werkende omgeving op te zetten.

Je kunt dit TransIP-script ook lokaal draaien om certificaten aan te maken, of in een Kubectl- of Helm chart.

#!/bin/bash

# Configuration settings
LOGIN_NAME=${LOGIN_NAME}
TTL=${TTL:-60}  # Default value 60 if TTL is not set
PRIVATE_KEY_PATH=${PRIVATE_KEY_PATH}
DOMAIN=${DOMAIN}

AUTH_URL="https://api.transip.nl/v6/auth"
LOG_FILE="/var/log/transip_dyndns.log"

# Function to update a log file
#log_message() {
#    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> $LOG_FILE
#}

# Function to log messages to stdout
log_message() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}

# Function to log an error and stop the script
handle_error() {
    log_message "ERROR: $1"
    echo "ERROR: $1" >&2
    exit 1
}

# Function to obtain a new access token with a short validity period (10 minutes)
generate_new_token() {
    log_message "Generating a new token with a validity period of 10 minutes."

    NONCE=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16)
    TIMESTAMP=$(date +%s)
    LABEL="Certbot DNS update $TIMESTAMP"

    REQUEST_BODY=$(printf '{"login":"%s","nonce":"%s","read_only":false,"expiration_time":"10 minutes","label":"%s","global_key":true}' "$LOGIN_NAME" "$NONCE" "$LABEL")

    log_message "Request Body: $REQUEST_BODY"

    SIGNATURE=$(echo -n "$REQUEST_BODY" | openssl dgst -sha512 -sign "$PRIVATE_KEY_PATH" | base64 | tr -d '\n')

    log_message "Generated signature: $SIGNATURE"

    RESPONSE=$(curl -s -X POST "$AUTH_URL" \
        -H "Signature: $SIGNATURE" \
        -H "Content-Type: application/json" \
        -d "$REQUEST_BODY") || handle_error "Failed to connect to TransIP API"

    log_message "API-response received: $RESPONSE"

    ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty')
    if [ -z "$ACCESS_TOKEN" ]; then
        handle_error "The received access token is empty or invalid. Check the API-response: $RESPONSE"
    fi

    log_message "Received token: $ACCESS_TOKEN"
}

# Function to add or update a TXT record
add_or_update_txt_record() {
    local full_domain="$1"
    local value="$2"

    # Extract the subdomain part from the full domain
    local name="_acme-challenge.${full_domain%%.*}"

    if [ -z "$name" ] || [ -z "$value" ]; then
        log_message "ERROR: Name or value for TXT record is missing."
        exit 1
    fi

    # Check if the DNS record already exists
    RESPONSE=$(curl -s -X GET \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        "https://api.transip.nl/v6/domains/$DOMAIN/dns")

    # Search for all existing records with the same name and type TXT
    MATCHING_RECORDS=$(echo "$RESPONSE" | jq -r --arg name "$name" '[.dnsEntries[] | select(.name==$name and .type=="TXT")]')

    # Check if there are multiple or no matching records
    MATCHING_COUNT=$(echo "$MATCHING_RECORDS" | jq 'length')

    # Ensure MATCHING_COUNT is always an integer
    if ! [[ "$MATCHING_COUNT" =~ ^[0-9]+$ ]]; then
        MATCHING_COUNT=0
    fi

    if [ "$MATCHING_COUNT" -eq 0 ]; then
        log_message "No existing TXT record found for $name. Creating a new record."
    elif [ "$MATCHING_COUNT" -ge 1 ]; then
        log_message "Multiple or one existing TXT record found for $name. Removing all existing records."

        # Remove all existing records using the full details of each record
        echo "$MATCHING_RECORDS" | jq -c '.[]' | while read -r record; do
            existing_name=$(echo "$record" | jq -r '.name')
            existing_expire=$(echo "$record" | jq -r '.expire')
            existing_content=$(echo "$record" | jq -r '.content')

            # Log the details of the record to be removed
            log_message "Remove record: name=$existing_name, expire=$existing_expire, content=$existing_content"

            # Make the DELETE call using the full DNS-entry details
            DELETE_ENTRY=$(jq -n \
            --arg name "$existing_name" \
            --arg expire "$existing_expire" \
            --arg type "TXT" \
            --arg content "$existing_content" \
            '{"dnsEntry": {"name": $name, "expire": ($expire | tonumber), "type": $type, "content": $content}}')

            DELETE_RESPONSE=$(curl -s -X DELETE \
                -H "Authorization: Bearer $ACCESS_TOKEN" \
                -H "Content-Type: application/json" \
                -d "$DELETE_ENTRY" \
                "https://api.transip.nl/v6/domains/$DOMAIN/dns")

            # Log the response of the DELETE call
            log_message "Response after removing $existing_name: $DELETE_RESPONSE"

            # Check if the removal was successful
            if [[ "$DELETE_RESPONSE" == *"error"* ]]; then
                log_message "ERROR: The record $existing_name could not be removed."
            else
                log_message "Record $existing_name successfully removed."
            fi
        done
    fi

    # Create a new record
    DNS_ENTRY=$(jq -n \
    --arg name "$name" \
    --arg expire "$TTL" \
    --arg type "TXT" \
    --arg content "$value" \
    '{"dnsEntry": {"name": $name, "expire": ($expire | tonumber), "type": $type, "content": $content}}')

    RESPONSE=$(curl -s -X POST \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        -d "$DNS_ENTRY" \
        "https://api.transip.nl/v6/domains/$DOMAIN/dns")

    log_message "API-response for TXT record creation: $RESPONSE"

    if [[ "$RESPONSE" == *"error"* ]]; then
        handle_error "The TXT record could not be created. Check the API-response: $RESPONSE"
    else
        log_message "TXT record successfully added for $name with value $value"
    fi
}

# Function to update an existing TXT record
update_txt_record() {
    local name="$1"
    local value="$2"

    DNS_ENTRY=$(jq -n \
    --arg name "$name" \
    --arg expire "$TTL" \
    --arg type "TXT" \
    --arg content "$value" \
    '{"dnsEntry": {"name": $name, "expire": ($expire | tonumber), "type": $type, "content": $content}}')

    RESPONSE=$(curl -s -X PATCH \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        -d "$DNS_ENTRY" \
        "https://api.transip.nl/v6/domains/$DOMAIN/dns")

    log_message "API-response for TXT record update: $RESPONSE"

    if [[ "$RESPONSE" == *"error"* ]]; then
        handle_error "The TXT record could not be updated. Check the API-response: $RESPONSE"
    else
        log_message "TXT record successfully updated for $name with value $value"
    fi
}

# Function to remove a TXT record for `dns-01` validation
remove_txt_record() {
    local full_domain="$1"

    # Extract the subdomain part from the full domain
    local name="_acme-challenge.${full_domain%%.*}"

    # Check if the DNS record exists
    RESPONSE=$(curl -s -X GET \
        -H "Authorization: Bearer $ACCESS_TOKEN" \
        "https://api.transip.nl/v6/domains/$DOMAIN/dns")

    # Search for all records with the same name and type TXT
    MATCHING_RECORDS=$(echo "$RESPONSE" | jq -r --arg name "$name" '[.dnsEntries[] | select(.name==$name and .type=="TXT")]')

    # Check if there are existing records that need to be removed
    MATCHING_COUNT=$(echo "$MATCHING_RECORDS" | jq 'length')

    if [ "$MATCHING_COUNT" -eq 0 ]; then
        log_message "No existing TXT record found for $name to remove."
    else
        log_message "Removing all existing TXT records for $name."

        # Loop through all found records and remove them
        echo "$MATCHING_RECORDS" | jq -c '.[]' | while read -r record; do
            existing_name=$(echo "$record" | jq -r '.name')
            existing_expire=$(echo "$record" | jq -r '.expire')
            existing_content=$(echo "$record" | jq -r '.content')

            # Make the DELETE call using the full details
            DELETE_ENTRY=$(jq -n \
            --arg name "$existing_name" \
            --arg expire "$existing_expire" \
            --arg type "TXT" \
            --arg content "$existing_content" \
            '{"dnsEntry": {"name": $name, "expire": ($expire | tonumber), "type": $type, "content": $content}}')

            DELETE_RESPONSE=$(curl -s -X DELETE \
                -H "Authorization: Bearer $ACCESS_TOKEN" \
                -H "Content-Type: application/json" \
                -d "$DELETE_ENTRY" \
                "https://api.transip.nl/v6/domains/$DOMAIN/dns")

            # Check if the removal was successful
            if [[ "$DELETE_RESPONSE" == *"error"* ]]; then
                log_message "ERROR: The TXT record $existing_name could not be removed."
            else
                log_message "TXT record $existing_name successfully removed."
            fi
        done
    fi
}

# Always generate a new token for each run
generate_new_token

# Certbot passes these values when adding and removing the record
if [ "$1" == "add" ]; then
    add_or_update_txt_record "$CERTBOT_DOMAIN" "$CERTBOT_VALIDATION"
    sleep 25  # Wait for DNS update to propagate
elif [ "$1" == "clean" ]; then
    remove_txt_record "$CERTBOT_DOMAIN"
fi

Stappen om te draaien

  1. Download de private key: Sla het bestand met de private key (transip_private_key.pem) uit de API-instellingen van TransIP op op het apparaat waar je dit gaat draaien.
  2. Stel Docker Compose in: Zet de bestanden docker-compose.yml en certbot-transip.sh in dezelfde map. (of pas het script aan)
  3. Start de container: Start de container (cd naar het pad waar je yml staat)
docker-compose up --build

Houd de logoutput in de gaten: de logoutput van de Docker container laat de voortgang van de DNS-challenges en het aanmaken van het certificaat zien.

Meer is het niet!

Binnenkort post ik ook een versie met een werkende Helm chart. Die was wat lastiger om alles correct te laten werken en alle certificaten te laten updaten via verschillende namespaces, waar ik de certificaten op verschillende domeinen beschikbaar moest hebben.