Let's Encrypt automatiseren met Certbot en TransIP met behulp van Docker

Wilt u uw SSL-certificaten automatisch bijwerken/beheren in uw Docker met Certbot en uw domein hosten bij TransIP? Zoek dan niet verder!

een jaar geleden   •   7 min lezen

Door Remco Loup.
Foto door Jakub Żerdzicki / Unsplash
Inhoudsopgave

Het beheren van SSL-certificaten voor domeinen die worden gehost op TransIP kan zonder automatisering omslachtig zijn. Gelukkig biedt TransIP een robuuste API voor DNS-beheer, maar om deze naadloos te integreren met Certbot voor dns-01 validatie is een aangepaste oplossing nodig. Aangezien ik geen kant-en-klaar script voor dit doel kon vinden, heb ik er zelf een gemaakt dat op een veilige manier authenticatie, DNS-updates en certificaatuitgifte afhandelt.

Je kunt hier de volledige API-documentatie bekijken:

TransIP API

Deze handleiding legt de volledige installatie met Docker uit, inclusief hoe u het API-sleutelpaar in de webinterface van TransIP kunt genereren en gebruiken voor geautomatiseerd certificaatbeheer.

Hoe TransIP-authenticatie werkt

TransIP gebruikt een RSA-sleutelpaar voor API-authenticatie. Hier volgt hoe u dit kunt instellen:

  1. Genereer het sleutelpaar in TransIP:
    • Log in op uw TransIP-configuratiescherm.
    • Ga naar API-instellingen onder uw account en schakel API-toegang in.
    • Genereer een nieuw API-sleutelpaar.
    • Download/bewaar de privésleutel (transip_private_key.pem) veilig en maak er een geheim van om te gebruiken in uw Docker-installatie.
docker secret create transip_private_key transip_private_key.pem
  1. Bewaar de privésleutel veilig:
    • Bewaar de privésleutel op een veilige plaats nadat u er een geheim van hebt gemaakt.
    • Deze privésleutel is nodig om verzoeken aan de API van TransIP te ondertekenen.
  2. Hoe het werkt:
    • Het script gebruikt de privésleutel om een authenticatieverzoek te ondertekenen.
    • De API van TransIP reageert met een kortstondig token (standaard 10 minuten), dat vervolgens wordt gebruikt om de DNS-records te beheren.
    • Het zal bij elke uitvoering een nieuw token aanmaken voor het gemak. Dit maakte het veerkrachtiger dan het controleren van een actief token en dat gebruiken, wat het hele script minder veerkrachtig en omslachtig maakte.

Wat deze opstelling gaat doen

  1. Automatiseert SSL-certificaten:
    • Automatiseert de uitgifte en verlenging van wildcard- en domeinspecifieke certificaten.
  2. Behandelt DNS-uitdagingen:
    • Beheert dns-01 uitdagingen door DNS TXT-records aan te maken en te verwijderen via de TransIP API.
  3. Naadloze integratie:
    • Alles wordt in een Docker-container ingekapseld voor draagbaarheid en eenvoud.
    • Je kunt domeinen verwijderen of toevoegen in dit script in het gedeelte -d rule. Ik heb de voorbeelden toegevoegd voor een betere uitleg.

We zullen een add- of clean-commando naar het bash-script pipen om de nodige acties uit te voeren zoals gedefinieerd in het script. Daarom gebruiken we hier de --manual-auth-hook en de manual-cleanup-hook. Dit zorgt ervoor dat een fout in de add-sectie bijvoorbeeld nog steeds wordt opgeruimd.

Je kunt ook een --force aan dit script toevoegen. Maar dan raak je snel door je pogingen bij Letsencrypt heen, omdat je maar een paar keer per 24 uur een aanvraag kunt indienen. Maar hierdoor worden de certificaten altijd vernieuwd wanneer je iets moet testen of verifiëren.

versie: "3.8"

services:
  certbot:
    image: certbot/certbot:latest
    container_name: certbot-transip
    secrets:
      - transip_private_key  # Verwijs naar het geheim dat u eerder hebt aangemaakt
    # pas deze volumes aan zodat ze naar de juiste .pem en het juiste script verwijzen. Op dit moment verwacht dit dockerfile dat de bestanden zich in dezelfde map bevinden als het docker-certbot-transip.yml
    volumes:
      - "./certbot-transip.sh:/usr/local/bin/certbot-transip.sh:ro"
      - "./letsencrypt:/etc/letsencrypt"
    omgeving:
    #Deze variabelen worden doorgegeven in ons bash-script.
      LOGIN_NAME: "your-transip-login"        # Vervang door uw TransIP-inlognaam.
      PRIVATE_KEY_PATH: "/etc/transip_private_key.pem"
      DOMAIN: "example.com"                  # Vervang door uw domein.
      TTL: "60"                              # Optioneel: Time-to-live voor DNS-uitdaging.
      CERTBOT_EMAIL: "you@example.com"       # Vervang door uw geldige e-mailadres zoals gevraagd door 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 moet al bestaan in Docker

Het Bash-script

Dit is het 'geavanceerde' gedeelte dat zijn werk doet en ervoor zorgt dat alles met TransIP werkt. Ik heb geprobeerd om het iets veerkrachtiger te maken dan standaard. Hopelijk helpt dit bij het creëren van een veerkrachtige en goed werkende omgeving.

U kunt dit TransIP-script ook gebruiken om het lokaal uit te voeren en 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 uit te voeren

  1. Download de privésleutel: Sla het privé-sleutelbestand op (transip_private_key.pem) uit de API-instellingen van TransIP ergens op het apparaat waarop u dit gaat uitvoeren.
  2. Docker Compose instellenPlaats de docker-compose.yml en certbot-transip.sh bestanden in dezelfde map. (of bewerk het script)
  3. Start de container: Start de container (cd naar het pad waar uw yml zich bevindt)
docker-compose up --build

Controleer de loguitvoer: De loguitvoer van de dockercontainer toont de voortgang van DNS-uitdagingen en de uitgifte van certificaten.

Dat is alles!

Ik zal binnenkort ook een versie met een werkende helm-chart plaatsen. Dit was een iets grotere uitdaging om ervoor te zorgen dat alles correct werkt en alle certificaten worden bijgewerkt via verschillende naamruimten waar ik de certificaten op verschillende domeinen beschikbaar moest hebben.

Vertel het verder

Blijf lezen