Last active
August 13, 2025 08:30
-
-
Save amanjuman/08729497d3d3404996802a6379955c96 to your computer and use it in GitHub Desktop.
EDCOM DKIM CSV to CloudFlare DNS
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
#!/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