Skip to content

Instantly share code, notes, and snippets.

@amanjuman
Last active August 13, 2025 08:30
Show Gist options
  • Save amanjuman/08729497d3d3404996802a6379955c96 to your computer and use it in GitHub Desktop.
Save amanjuman/08729497d3d3404996802a6379955c96 to your computer and use it in GitHub Desktop.
EDCOM DKIM CSV to CloudFlare DNS
#!/usr/bin/env bash
set -euo pipefail
# --------------------------------------------
# Cloudflare DKIM TXT creator/updater from CSV
# CSV columns (header required):
# domain,record,value
# Example:
# example.com,selector1._domainkey,v=DKIM1; p=MIIBIjANBgkqh...
# --------------------------------------------
# CloudFlare API credentials
API_KEY="xxx"
EMAIL="xxx"
# Force wrapping TXT content in quotes (Cloudflare-friendly)
QUOTE_TXT=${QUOTE_TXT:-true}
# CSV file path and error log
CSV_FILE="dkim.csv"
ERROR_LOG="error.log"
: > "$ERROR_LOG" # Clear log
# Dependencies
command -v jq >/dev/null || { echo "jq not found. Please install jq." >&2; exit 1; }
auth_headers=(-H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $API_KEY")
api() {
local method="$1"; shift
local url="$1"; shift
curl -sS -X "$method" "$url" \
-H "Content-Type: application/json" \
"${auth_headers[@]}" "$@"
}
# Trim leading/trailing whitespace
trim() { sed -e 's/^[[:space:]]\+//' -e 's/[[:space:]]\+$//'; }
# Remove all control characters (fixes \013, \r, etc.); keep printable ASCII
sanitize() {
tr -d '\n' | tr -cd '\40-\176'
}
# Strip *one* pair of surrounding quotes if present
strip_surrounding_quotes() {
sed -E 's/^"(.*)"$/\1/'
}
# Ensure value is quoted exactly once if QUOTE_TXT=true
maybe_quote_value() {
local v="$1"
if [[ "${QUOTE_TXT}" == "true" ]]; then
# If already wrapped in a single pair, leave it; otherwise wrap
if [[ "$v" =~ ^\".*\"$ ]]; then
printf '%s' "$v"
else
printf '"%s"' "$v"
fi
else
printf '%s' "$v"
fi
}
get_zone_id() {
local domain="$1"
local resp
resp="$(api GET "https://api.cloudflare.com/client/v4/zones?name=${domain}")" || true
echo "$resp" >>"$ERROR_LOG"
echo "$resp" | jq -r '.result[0].id // empty'
}
find_txt_record_id() {
local zone_id="$1" name="$2"
local resp
resp="$(api GET "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=TXT&name=${name}&per_page=100")" || true
echo "$resp" >>"$ERROR_LOG"
echo "$resp" | jq -r '.result[0].id // empty'
}
create_txt() {
local zone_id="$1" name="$2" content="$3"
local payload
payload="$(jq -n --arg type "TXT" --arg name "$name" --arg content "$content" \
--argjson ttl 1 --argjson proxied false \
'{type:$type,name:$name,content:$content,ttl:$ttl,proxied:$proxied}')"
echo "CREATE payload: $payload" >>"$ERROR_LOG"
api POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" --data "$payload"
}
update_txt() {
local zone_id="$1" rec_id="$2" name="$3" content="$4"
local payload
payload="$(jq -n --arg type "TXT" --arg name "$name" --arg content "$content" \
--argjson ttl 1 --argjson proxied false \
'{type:$type,name:$name,content:$content,ttl:$ttl,proxied:$proxied}')"
echo "UPDATE payload: $payload" >>"$ERROR_LOG"
api PUT "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${rec_id}" --data "$payload"
}
# Read CSV, skip header
{
IFS= read -r _header || true
while IFS=, read -r raw_domain raw_record raw_value || [[ -n "${raw_domain:-}${raw_record:-}${raw_value:-}" ]]; do
domain="$(printf '%s' "${raw_domain:-}" | strip_surrounding_quotes | trim)"
record="$(printf '%s' "${raw_record:-}" | strip_surrounding_quotes | trim)"
# Clean the value, then apply quoting policy
base_value="$(printf '%s' "${raw_value:-}" | strip_surrounding_quotes | trim | sanitize)"
value="$(maybe_quote_value "$base_value")"
if [[ -z "$domain" || -z "$record" || -z "$base_value" ]]; then
echo "Invalid CSV row -> domain:'$domain' record:'$record' value length: ${#base_value}" | tee -a "$ERROR_LOG"
continue
fi
# Ensure FQDN for record
name="$record"
if [[ "$name" != *".${domain}" && "$name" != "$domain" ]]; then
name="${record}.${domain}"
fi
# Zone
zone_id="$(get_zone_id "$domain")"
if [[ -z "$zone_id" ]]; then
echo "Zone not found for domain: $domain" | tee -a "$ERROR_LOG"
continue
fi
# Existing?
rec_id="$(find_txt_record_id "$zone_id" "$name")"
if [[ -z "$rec_id" ]]; then
resp="$(create_txt "$zone_id" "$name" "$value")" || true
echo "Create response: $resp" >>"$ERROR_LOG"
if [[ "$(echo "$resp" | jq -r '.success')" == "true" ]]; then
echo "Created TXT: $name"
else
echo "Failed to create TXT: $name" | tee -a "$ERROR_LOG"
fi
else
current_content="$(api GET "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${rec_id}" \
| jq -r '.result.content // ""')"
if [[ "$current_content" == "$value" ]]; then
echo "No change (already up-to-date): $name"
else
resp="$(update_txt "$zone_id" "$rec_id" "$name" "$value")" || true
echo "Update response: $resp" >>"$ERROR_LOG"
if [[ "$(echo "$resp" | jq -r '.success')" == "true" ]]; then
echo "Updated TXT: $name"
else
echo "Failed to update TXT: $name" | tee -a "$ERROR_LOG"
fi
fi
fi
sleep 1
done
} < "$CSV_FILE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment