Skip to content

Commit 8b6a69d

Browse files
committed
dns-update: use batch api (#1)
* ech-rotate: add rollback mechanism * dns-update: use batch api * improve logging of dns-update and ech-rotate * enhance changes
1 parent 3bea7f9 commit 8b6a69d

2 files changed

Lines changed: 142 additions & 53 deletions

File tree

ech-rotate.sh

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ SUBDOMAINS="${SUBDOMAINS:?Must set SUBDOMAINS (space-separated list)}"
2424
ECH_ROTATION="${ECH_ROTATION:-false}" # default: disabled
2525
KEEP_KEYS="${KEEP_KEYS:-3}" # number of old timestamped keys to keep
2626

27+
reload_nginx() {
28+
if [[ -f "$PIDFILE" ]]; then
29+
PID=$(cat "$PIDFILE")
30+
if kill -0 "$PID" 2>/dev/null; then
31+
kill -SIGHUP "$PID"
32+
log "Reloaded nginx (pid $PID)"
33+
else
34+
log "Nginx PID $PID is not yet running"
35+
fi
36+
else
37+
log "PID file not found: $PIDFILE"
38+
fi
39+
}
40+
2741
rotate_ech() {
2842
log "Rotating ECH keys..."
2943
mkdir -p "$ECH_DIR" || log "Failed to create $ECH_DIR"
@@ -35,27 +49,88 @@ rotate_ech() {
3549

3650
# 2. Ensure symlinks exist, fill missing ones with latest
3751
cd "$ECH_DIR" || return 1
52+
53+
# Before rotation, capture current symlinks for rollback
54+
old_latest=$(readlink -f "$DOMAIN.ech" 2>/dev/null || true)
55+
old_previous=$(readlink -f "$DOMAIN.previous.ech" 2>/dev/null || true)
56+
old_stale=$(readlink -f "$DOMAIN.stale.ech" 2>/dev/null || true)
57+
3858
ln -sf "$(readlink "$DOMAIN.previous.ech")" "$DOMAIN.stale.ech"
3959
ln -sf "$(readlink "$DOMAIN.ech")" "$DOMAIN.previous.ech"
4060
ln -sf "$(basename "$NEW_KEY")" "$DOMAIN.ech"
4161
log "Symlinks rotated: ech -> $(readlink "$DOMAIN.ech"), previous.ech -> $(readlink "$DOMAIN.previous.ech"), stale.ech -> $(readlink "$DOMAIN.stale.ech")"
4262

4363
# 4. Reload nginx
44-
if [[ -f "$PIDFILE" ]]; then
45-
PID=$(cat "$PIDFILE")
46-
if kill -0 "$PID" 2>/dev/null; then
47-
kill -SIGHUP "$PID"
48-
log "Reloaded nginx (pid $PID)"
49-
else
50-
log "Nginx PID $PID is not yet running"
51-
fi
52-
else
53-
log "PID file not found: $PIDFILE"
54-
fi
64+
reload_nginx
65+
66+
# 5. Backup DNS records for rollback
67+
backup_file=$(mktemp)
68+
curl -s -X GET "$CF_ZONE_URL/$CF_ZONE_ID/dns_records?type=HTTPS" \
69+
-H "Authorization: Bearer $CF_API_TOKEN" \
70+
-H "Content-Type: application/json" \
71+
> "$backup_file"
5572

5673
# 5-6. Update DNS Records
5774
source /usr/local/bin/update_https_records.sh
58-
update_https_records || { log "Error: Failed to update HTTPS DNS records"; return 1; }
75+
# DNS update
76+
if ! update_https_records; then
77+
log "Error: Failed to update HTTPS DNS records, rolling back ECH keys in nginx..."
78+
79+
# Roll back symlinks to old state
80+
[[ -n "$old_latest" ]] && ln -sf "$(basename "$old_latest")" "$DOMAIN.ech"
81+
[[ -n "$old_previous" ]] && ln -sf "$(basename "$old_previous")" "$DOMAIN.previous.ech"
82+
[[ -n "$old_stale" ]] && ln -sf "$(basename "$old_stale")" "$DOMAIN.stale.ech"
83+
84+
# Optionally delete the new key if not needed
85+
rm -f -- "$NEW_KEY"
86+
log "Deleted the newly generated key: ${NEW_KEY}"
87+
reload_nginx
88+
89+
log "Rolling back DNS updates"
90+
# Get current state
91+
current_file=$(mktemp)
92+
curl -s -X GET "$CF_ZONE_URL/$CF_ZONE_ID/dns_records?type=HTTPS" \
93+
-H "Authorization: Bearer $CF_API_TOKEN" \
94+
-H "Content-Type: application/json" \
95+
> "$current_file"
96+
97+
# Collect rollback candidates
98+
ROLLBACK=()
99+
while IFS= read -r rec; do
100+
rec_id=$(jq -r '.id' <<<"$rec")
101+
cur=$(jq -c --arg id "$rec_id" '.result[] | select(.id==$id)' "$current_file")
102+
103+
if [[ "$rec" != "$cur" ]]; then
104+
log "Will restore record $rec_id"
105+
# Make sure to keep only fields CF accepts, including id
106+
clean=$(jq '{id, type, name, content, ttl, proxied, priority, data, comment, tags}' <<<"$rec")
107+
ROLLBACK+=("$clean")
108+
fi
109+
done < <(jq -c '.result[]' "$backup_file")
110+
111+
if [ "${#ROLLBACK[@]}" -gt 0 ]; then
112+
# Build batch body with puts
113+
PUTS_JSON=$(printf '%s\n' "${ROLLBACK[@]}" | jq -s '.')
114+
BATCH=$(jq -n --argjson puts "$PUTS_JSON" '{puts:$puts}')
115+
116+
log "Submitting rollback batch with ${#ROLLBACK[@]} records: $BATCH"
117+
CF_RESULT=$(curl -s -X POST "$CF_ZONE_URL/$CF_ZONE_ID/dns_records/batch" \
118+
-H "Authorization: Bearer $CF_API_TOKEN" \
119+
-H "Content-Type: application/json" \
120+
--data "$BATCH")
121+
122+
if echo "$CF_RESULT" | grep -q '"success":true'; then
123+
log "Rollback batch applied successfully"
124+
else
125+
log "Rollback batch failed: $CF_RESULT"
126+
fi
127+
else
128+
log "No changes detected, nothing to rollback"
129+
fi
130+
131+
log "ECH key rotation failed, rollback successful"
132+
return 1
133+
fi
59134

60135
# 7. Cleanup old keys (keep latest N timestamped files, skip symlink targets)
61136
cd "$ECH_DIR" || return 1

update_https_records.sh

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
update_https_records() {
55
log "Updating DNS HTTPS records..."
6-
6+
77
# 1. Extract ECHConfig from new key
88
ECHCONFIG=$(awk '/-----BEGIN ECHCONFIG-----/{flag=1;next}/-----END ECHCONFIG-----/{flag=0}flag' "$NEW_KEY" | tr -d '\n')
99
if [[ -z "$ECHCONFIG" ]]; then
@@ -23,53 +23,67 @@ update_https_records() {
2323

2424
# Common curl options
2525
# 3. Publish HTTPS DNS record to Cloudflare (update only ech field)
26+
# Common curl options
27+
# use the DNS batch API schema (posts, patches, puts, deletes)
2628
CURL_OPTS=(-s --retry 5 --retry-delay 2 --retry-connrefused)
27-
for d in "${SUBDOMAINS_ARR[@]}"; do
28-
RECORD=$(curl "${CURL_OPTS[@]}" -X GET "$CF_ZONE_URL/$CF_ZONE_ID/dns_records?type=HTTPS&name=$d" \
29-
-H "Authorization: Bearer $CF_API_TOKEN" \
30-
-H "Content-Type: application/json")
3129

32-
RECORD_ID=$(echo "$RECORD" | jq -r '.result[0].id')
33-
RECORD_DATA=$(echo "$RECORD" | jq '.result[0].data')
30+
POSTS=()
31+
PATCHES=()
32+
33+
# Fetch all HTTPS records once
34+
ALL_RECORDS=$(curl "${CURL_OPTS[@]}" -G \
35+
--data-urlencode "type=HTTPS" \
36+
-H "Authorization: Bearer $CF_API_TOKEN" \
37+
-H "Content-Type: application/json" \
38+
"$CF_ZONE_URL/$CF_ZONE_ID/dns_records")
3439

35-
if [[ "$RECORD_ID" == "null" ]]; then
36-
log "No HTTPS record found for $d, inserting new HTTPS record"
37-
UPDATED_DATA=$(jq -n --arg ech "$ECHCONFIG" '{
38-
value: "ech=\"\($ech)\"",
39-
priority: "1",
40-
target: ".",
41-
}')
42-
METHOD="POST"
43-
URL="$CF_ZONE_URL/$CF_ZONE_ID/dns_records"
40+
for d in "${SUBDOMAINS_ARR[@]}"; do
41+
RECORD_RAW=$(jq --arg name "$d" '.result[] | select(.name==$name)' <<<"$ALL_RECORDS")
42+
43+
if [ -z "$RECORD_RAW" ]; then
44+
# no existing record -> prepare POST
45+
POSTS+=( "$(jq -n --arg name "$d" --arg ech "$ECHCONFIG" '{
46+
name: $name,
47+
type: "HTTPS",
48+
data: { value: ("ech=\"" + $ech + "\""), priority: "1", target: "." }
49+
}')" )
4450
else
45-
log "HTTPS record found for $d, updating ech public key"
46-
# Replace the ech record in HTTPS DNS record
47-
UPDATED_DATA=$(echo "$RECORD_DATA" \
48-
| jq --arg ECH "$ECHCONFIG" '
49-
if .value | test("ech=")
50-
then .value |= sub("ech=\"[^\"]*\""; "ech=\"\($ECH)\"")
51-
else .value += " ech=\"\($ECH)\""
52-
end
53-
')
54-
METHOD="PUT"
55-
URL="$CF_ZONE_URL/$CF_ZONE_ID/dns_records/$RECORD_ID"
51+
# existing record -> produce a minimal patch object
52+
PATCHES+=( "$(jq --arg ECH "$ECHCONFIG" '{
53+
id,
54+
data: (
55+
.data | .value |= (
56+
if test("ech=") then
57+
sub("ech=\"[^\"]*\""; "ech=\"\($ECH)\"")
58+
else
59+
. + " ech=\"\($ECH)\""
60+
end
61+
)
62+
)
63+
}' <<<"$RECORD_RAW")" )
5664
fi
65+
done
5766

58-
UPDATED_DATA=$(jq -n --arg name "$d" --argjson data "$UPDATED_DATA" '{type:"HTTPS", name:$name, data:$data}')
59-
log "Pushing updated HTTPS record for $d: $UPDATED_DATA"
67+
# build JSON arrays (empty arrays if there are no items)
68+
POSTS_JSON=$(printf '%s\n' "${POSTS[@]}" | jq -s '.' )
69+
PATCHES_JSON=$(printf '%s\n' "${PATCHES[@]}" | jq -s '.' )
6070

61-
sleep 0.3
71+
# final batch body: include only the arrays you need (Cloudflare accepts empty arrays)
72+
BATCH=$(jq -n --argjson posts "$POSTS_JSON" --argjson patches "$PATCHES_JSON" '{posts:$posts, patches:$patches}')
73+
log "Submitting API curl batch update: $BATCH"
6274

63-
CF_RESULT=$(curl "${CURL_OPTS[@]}" -X "$METHOD" "$URL" \
64-
-H "Authorization: Bearer $CF_API_TOKEN" \
65-
-H "Content-Type: application/json" \
66-
--data "$UPDATED_DATA") || log "Failed to push DNS record for $d"
75+
# send the batch
76+
CF_RESULT=$(curl "${CURL_OPTS[@]}" -X POST "$CF_ZONE_URL/$CF_ZONE_ID/dns_records/batch" \
77+
-H "Authorization: Bearer $CF_API_TOKEN" \
78+
-H "Content-Type: application/json" \
79+
--data "$BATCH")
6780

68-
if echo "$CF_RESULT" | grep -q '"success":true'; then
69-
log "Updated ech for $d (record $RECORD_ID)"
70-
else
71-
log "Failed to update ech for $d: $CF_RESULT"
72-
fi
73-
sleep 0.3
74-
done
81+
# check success
82+
if jq -e '.success == true' >/dev/null 2>&1 <<<"$CF_RESULT"; then
83+
log "Cloudflare batch update success"
84+
else
85+
log "Cloudflare batch update failed:"
86+
echo "$CF_RESULT" | jq -C . >&2
87+
return 1
88+
fi
7589
}

0 commit comments

Comments
 (0)