Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save sigismund/ad7049e4675727f6596e728198e76412 to your computer and use it in GitHub Desktop.
Save sigismund/ad7049e4675727f6596e728198e76412 to your computer and use it in GitHub Desktop.
Bulk sync Cloudflare Custom Trust Store, so it can be used in combination with common CAs and with additional custom CA.
#!/bin/bash
# Simple CA Trust Store Sync Script
# Downloads Mozilla CA bundle and syncs with Cloudflare Custom Trust Store
set -e
# Configuration
MOZILLA_CA_URL="${MOZILLA_CA_URL:-https://curl.se/ca/cacert.pem}"
ZONE_ID=""
DRY_RUN=false
MAX_CERTS=0 # 0 means no limit
PRESERVE_MANUAL_CAS=true
LOG_LEVEL="${LOG_LEVEL:-info}"
EXTERNAL_CA_DIR=""
EXTERNAL_CA_BUNDLE=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--zone-id)
ZONE_ID="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--max-certs)
MAX_CERTS="$2"
shift 2
;;
--preserve-manual)
PRESERVE_MANUAL_CAS=true
shift
;;
--no-preserve-manual)
PRESERVE_MANUAL_CAS=false
shift
;;
--log-level)
LOG_LEVEL="$2"
shift 2
;;
--external-ca-dir)
EXTERNAL_CA_DIR="$2"
shift 2
;;
--external-ca-bundle)
EXTERNAL_CA_BUNDLE="$2"
shift 2
;;
--mozilla-ca-url)
MOZILLA_CA_URL="$2"
shift 2
;;
--help)
echo "Usage: $0 --zone-id ZONE_ID [OPTIONS]"
echo " --zone-id ZONE_ID : Cloudflare Zone ID"
echo " --dry-run : Show what would be done without making changes"
echo " --max-certs NUMBER : Limit the number of certificates to process (for testing)"
echo " --preserve-manual : Preserve manually added CA certificates (default)"
echo " --no-preserve-manual : Remove manually added CA certificates not in Mozilla bundle"
echo " --log-level LEVEL : Set log level (debug, info, warn, error)"
echo " --external-ca-dir DIR : Directory containing additional CA certificate files"
echo " --external-ca-bundle FILE : Path to additional CA certificate bundle file"
echo " --mozilla-ca-url URL : URL to download Mozilla CA bundle"
exit 0
;;
*)
echo "Error: Unknown option $1"
exit 1
;;
esac
done
# Validate required arguments
if [[ -z "$ZONE_ID" ]]; then
echo "Error: Zone ID is required. Use --zone-id ZONE_ID"
exit 1
fi
# Check dependencies
for cmd in curl jq openssl; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: Required command '$cmd' not found"
exit 1
fi
done
# Setup temporary directory
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
echo "Starting CA Trust Store sync for zone: $ZONE_ID"
# Download Mozilla CA bundle
echo "Downloading Mozilla CA bundle..."
if ! curl -fsSL "$MOZILLA_CA_URL" -o "$TEMP_DIR/mozilla-bundle.pem"; then
echo "Error: Failed to download Mozilla CA bundle"
exit 1
fi
# Split PEM bundle into individual certificates
echo "Processing certificates..."
awk '
/-----BEGIN CERTIFICATE-----/ { cert = $0 "\n"; in_cert = 1; next }
in_cert { cert = cert $0 "\n" }
/-----END CERTIFICATE-----/ {
if (in_cert) {
print cert > "'$TEMP_DIR'/cert-" ++count ".pem"
cert = ""
in_cert = 0
}
}
' "$TEMP_DIR/mozilla-bundle.pem"
# Process each certificate and build JSON array
echo "Extracting certificate data..."
echo "[]" > "$TEMP_DIR/mozilla-cas.json"
for cert_file in "$TEMP_DIR"/cert-*.pem; do
if [[ -f "$cert_file" ]]; then
# Extract subject and certificate content
cert_content=$(cat "$cert_file")
subject=$(echo "$cert_content" | openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null | sed 's/^subject=//' || echo "unknown")
if [[ "$subject" != "unknown" ]]; then
# Create JSON object and add to array
jq --arg cert "$cert_content" --arg subj "$subject" \
'. += [{"certificate": $cert, "subject": $subj}]' \
"$TEMP_DIR/mozilla-cas.json" > "$TEMP_DIR/temp.json" && \
mv "$TEMP_DIR/temp.json" "$TEMP_DIR/mozilla-cas.json"
fi
fi
done
mozilla_count=$(jq length "$TEMP_DIR/mozilla-cas.json")
echo "Processed $mozilla_count Mozilla CA certificates"
# Process external CA certificates if provided
if [[ -n "$EXTERNAL_CA_DIR" && -d "$EXTERNAL_CA_DIR" ]]; then
echo "Processing external CA certificates from directory: $EXTERNAL_CA_DIR"
# Find certificate files in the external directory
find "$EXTERNAL_CA_DIR" -type f \( -name "*.pem" -o -name "*.crt" -o -name "*.cer" \) | while read -r ext_cert_file; do
if [[ -f "$ext_cert_file" ]]; then
echo " Processing external certificate: $(basename "$ext_cert_file")"
# Read certificate content
ext_cert_content=$(cat "$ext_cert_file")
ext_subject=$(echo "$ext_cert_content" | openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null | sed 's/^subject=//' || echo "unknown")
if [[ "$ext_subject" != "unknown" ]]; then
# Add to Mozilla CAs list
jq --arg cert "$ext_cert_content" --arg subj "$ext_subject" \
'. += [{"certificate": $cert, "subject": $subj}]' \
"$TEMP_DIR/mozilla-cas.json" > "$TEMP_DIR/temp.json" && \
mv "$TEMP_DIR/temp.json" "$TEMP_DIR/mozilla-cas.json"
fi
fi
done
# Update count after adding external certificates
mozilla_count=$(jq length "$TEMP_DIR/mozilla-cas.json")
echo "Total certificates after adding external CAs: $mozilla_count"
fi
# Process external CA bundle if provided
if [[ -n "$EXTERNAL_CA_BUNDLE" && -f "$EXTERNAL_CA_BUNDLE" ]]; then
echo "Processing external CA bundle: $EXTERNAL_CA_BUNDLE"
# Split external bundle into individual certificates
awk '
/-----BEGIN CERTIFICATE-----/ { cert = $0 "\n"; in_cert = 1; next }
in_cert { cert = cert $0 "\n" }
/-----END CERTIFICATE-----/ {
if (in_cert) {
print cert > "'$TEMP_DIR'/ext-cert-" ++count ".pem"
cert = ""
in_cert = 0
}
}
' "$EXTERNAL_CA_BUNDLE"
# Process each external certificate
for ext_cert_file in "$TEMP_DIR"/ext-cert-*.pem; do
if [[ -f "$ext_cert_file" ]]; then
ext_cert_content=$(cat "$ext_cert_file")
ext_subject=$(echo "$ext_cert_content" | openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null | sed 's/^subject=//' || echo "unknown")
if [[ "$ext_subject" != "unknown" ]]; then
# Add to Mozilla CAs list
jq --arg cert "$ext_cert_content" --arg subj "$ext_subject" \
'. += [{"certificate": $cert, "subject": $subj}]' \
"$TEMP_DIR/mozilla-cas.json" > "$TEMP_DIR/temp.json" && \
mv "$TEMP_DIR/temp.json" "$TEMP_DIR/mozilla-cas.json"
fi
fi
done
# Update count after adding external bundle certificates
mozilla_count=$(jq length "$TEMP_DIR/mozilla-cas.json")
echo "Total certificates after adding external bundle: $mozilla_count"
fi
# Get current CAs from Cloudflare
echo "Fetching current CAs from Cloudflare..."
# Prepare API endpoint for custom trust store
API_ENDPOINT="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/acm/custom_trust_store"
# Create a temporary file for the raw response
raw_response_file="${TEMP_DIR}/raw_response.txt"
# Fetch current certificates with proper error handling
curl_exit_code=0
if [[ -n "$CLOUDFLARE_API_TOKEN" ]]; then
echo "Using API Token authentication"
curl -s -w "\nHTTP_CODE:%{http_code}\n" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"$API_ENDPOINT" > "$raw_response_file" 2>"${raw_response_file}.headers"
curl_exit_code=$?
elif [[ -n "$CLOUDFLARE_EMAIL" && -n "$CLOUDFLARE_API_KEY" ]]; then
echo "Using Email + API Key authentication"
curl -s -w "\nHTTP_CODE:%{http_code}\n" \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
"$API_ENDPOINT" > "$raw_response_file" 2>"${raw_response_file}.headers"
curl_exit_code=$?
else
echo "Error: No Cloudflare authentication found. Set CLOUDFLARE_API_TOKEN or CLOUDFLARE_EMAIL+CLOUDFLARE_API_KEY"
exit 1
fi
# Check curl exit code
if [[ $curl_exit_code -ne 0 ]]; then
echo "Error: curl failed with exit code $curl_exit_code"
echo "Headers: $(cat "${raw_response_file}.headers" 2>/dev/null || echo "No headers")"
exit 1
fi
# Extract HTTP status code and clean response
http_code=$(grep "HTTP_CODE:" "$raw_response_file" | cut -d: -f2 || echo "unknown")
sed -i '/^HTTP_CODE:/d' "$raw_response_file" # Remove HTTP_CODE line from response
echo "HTTP Status Code: $http_code"
echo "API Response Body (first 200 chars):"
head -c 200 "$raw_response_file"
echo ""
# Check HTTP status code
if [[ "$http_code" != "200" ]]; then
echo "Error: API returned HTTP $http_code"
echo "Full response: $(cat "$raw_response_file")"
exit 1
fi
# Check if response is empty
if [[ ! -s "$raw_response_file" ]]; then
echo "Error: Empty response from API"
exit 1
fi
# Check if response looks like JSON (has opening brace)
if ! grep -q '^[[:space:]]*{' "$raw_response_file"; then
echo "Error: Response doesn't appear to be valid JSON"
echo "Response starts with: $(head -c 50 "$raw_response_file")"
exit 1
fi
# Validate JSON syntax before processing with jq
if ! jq . "$raw_response_file" >/dev/null 2>&1; then
echo "Error: Invalid JSON in API response"
echo "jq validation error. First 500 chars of response:"
head -c 500 "$raw_response_file"
exit 1
fi
# Read the response after validation
response=$(cat "$raw_response_file")
# Check for success in response
if ! echo "$response" | jq -e '.success' >/dev/null 2>&1 || ! echo "$response" | jq -e '.success == true' >/dev/null 2>&1; then
echo "Error: API call was not successful"
echo "Response: $response"
# Try to extract error message
error_msg=$(echo "$response" | jq -r '.errors[0].message // "Unknown error"' 2>/dev/null || echo "Could not parse error")
echo "Error message: $error_msg"
exit 1
fi
# Process the API response to extract current certificates
echo "Processing API response for current certificates..."
# Initialize with empty array
echo "[]" > "$TEMP_DIR/current-cas.json"
# Check if response has the expected structure
if echo "$response" | jq -e '.result' >/dev/null 2>&1; then
echo "Response contains 'result' field"
# Check if result is an array (Cloudflare Custom Trust Store format)
if echo "$response" | jq -e '.result | type == "array"' >/dev/null 2>&1; then
echo "Result is an array of certificates"
# Extract certificate data from the array
certificates_count=$(echo "$response" | jq '.result | length')
echo "Found $certificates_count existing certificates in API response"
if [[ $certificates_count -gt 0 ]]; then
# Convert Cloudflare certificate format to our internal format
echo "$response" | jq '.result | map({
certificate: (.certificate // .pem // ""),
subject: (.issuer // .subject // "Unknown")
}) | map(select(.certificate != ""))' > "$TEMP_DIR/current-cas.json"
converted_count=$(jq length "$TEMP_DIR/current-cas.json")
echo "Converted $converted_count certificates to internal format"
else
echo "No certificates found in trust store (empty array)"
fi
else
# Check for nested certificates field (alternative format)
if echo "$response" | jq -e '.result.certificates' >/dev/null 2>&1; then
echo "Response contains nested 'certificates' field"
# Extract certificates array safely
certificates=$(echo "$response" | jq -r '.result.certificates' 2>/dev/null)
if [[ $? -eq 0 && "$certificates" != "null" && "$certificates" != "[]" ]]; then
echo "Found existing certificates in nested format"
echo "$certificates" > "$TEMP_DIR/current-cas.json"
else
echo "No existing certificates found (empty nested array)"
fi
else
echo "No certificates found in response format"
fi
fi
else
echo "No result field found in response"
fi
echo "Using current certificate list for comparison"
current_count=$(jq length "$TEMP_DIR/current-cas.json")
echo "Found $current_count current CAs in Cloudflare Custom Trust Store"
# Calculate differences
echo "Calculating differences..."
# Find certificates to add (in Mozilla but not in current)
jq -n \
--slurpfile mozilla "$TEMP_DIR/mozilla-cas.json" \
--slurpfile current "$TEMP_DIR/current-cas.json" \
'$mozilla[0] | map(.certificate) | map(select(. as $cert | $current[0] | map(.certificate) | index($cert) | not)) | map({certificate: ., subject: "CA Certificate"})' \
> "$TEMP_DIR/to-add.json"
# Apply max certificates limit if specified
if [[ $MAX_CERTS -gt 0 ]]; then
echo "Limiting to $MAX_CERTS certificates (testing mode)"
jq -n --slurpfile certs "$TEMP_DIR/to-add.json" \
--argjson max "$MAX_CERTS" \
'$certs[0] | .[:$max]' \
> "$TEMP_DIR/to-add-limited.json"
mv "$TEMP_DIR/to-add-limited.json" "$TEMP_DIR/to-add.json"
fi
# Find certificates to remove (in current but not in Mozilla) - skip for safety
echo "[]" > "$TEMP_DIR/to-remove.json"
add_count=$(jq length "$TEMP_DIR/to-add.json")
remove_count=$(jq length "$TEMP_DIR/to-remove.json")
echo "CAs to add: $add_count"
echo "CAs to remove: $remove_count"
# Apply changes
if [[ $add_count -eq 0 && $remove_count -eq 0 ]]; then
echo "No changes needed - trust store is up to date"
exit 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo "DRY RUN - Would add $add_count CAs"
echo "Sample certificate to be added:"
jq -r '.[0] | " Subject: " + .subject' "$TEMP_DIR/to-add.json"
echo " Certificate preview: $(jq -r '.[0].certificate' "$TEMP_DIR/to-add.json" | head -3 | tr '\n' ' ')..."
exit 0
fi
# Add new certificates
if [[ $add_count -gt 0 ]]; then
echo "Adding $add_count CAs to trust store..."
success=0
failed=0
# Custom Trust Store API requires individual certificate uploads via POST
# Process certificates one by one
echo " Adding certificates individually (API limitation)..."
# Custom Trust Store API endpoint
API_ENDPOINT="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/acm/custom_trust_store"
# Read certificates to add
cert_index=0
while read -r cert_data; do
cert_index=$((cert_index + 1))
cert_pem=$(echo "$cert_data" | jq -r '.certificate')
cert_subject=$(echo "$cert_data" | jq -r '.subject')
echo " [$cert_index/$add_count] Adding certificate: $(echo "$cert_subject" | head -c 50)..."
# Create individual certificate payload
cert_payload=$(jq -n --arg cert "$cert_pem" '{"certificate": $cert}')
# Save response to a temporary file
response_file="${TEMP_DIR}/response-${cert_index}-$RANDOM.txt"
# Add individual certificate using POST method
if [[ -n "$CLOUDFLARE_API_TOKEN" ]]; then
curl --connect-timeout 10 --max-time 30 -s -X POST \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$cert_payload" \
"$API_ENDPOINT" \
-o "$response_file" 2>"${response_file}.headers"
curl_exit_code=$?
else
curl --connect-timeout 10 --max-time 30 -s -X POST \
-H "X-Auth-Email: $CLOUDFLARE_EMAIL" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
-H "Content-Type: application/json" \
-d "$cert_payload" \
"$API_ENDPOINT" \
-o "$response_file" 2>"${response_file}.headers"
curl_exit_code=$?
fi
# Check results
if [[ $curl_exit_code -ne 0 ]]; then
echo " ✗ Curl failed with exit code $curl_exit_code"
failed=$((failed + 1))
elif [[ ! -s "$response_file" ]]; then
echo " ✗ Empty response from API"
failed=$((failed + 1))
elif grep -q '"success":true' "$response_file"; then
echo " ✓ Added successfully"
success=$((success + 1))
else
echo " ✗ Failed to add certificate"
failed=$((failed + 1))
# Try to extract error message
error_msg=$(grep -o '"message":"[^"]*"' "$response_file" | head -1 | cut -d':' -f2- | tr -d '"')
if [[ -n "$error_msg" ]]; then
echo " Error: $error_msg"
fi
fi
# Clean up response file
rm -f "$response_file" "${response_file}.headers"
# Add small delay between requests to avoid rate limiting
sleep 0.1
done < <(jq -c '.[]' "$TEMP_DIR/to-add.json")
echo "Completed: $success successful, $failed failed"
fi
echo "CA Trust Store sync completed"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment