Created
June 17, 2025 09:00
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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