Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/dns-recovery-recipient.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
age1upjezmgf4n246gnnwx76gt2fe8e42pnnx2x7kkddzdgf0ja3pcqqr6q6ln
7 changes: 7 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ jobs:
tofu_version: 1.12.6
tofu_wrapper: false # The YAML parser test passes an expression through console stdin.

- name: Install the pinned encryption tool for native snapshot tests
run: |
curl --disable --fail --silent --show-error --proto '=https' --location --max-redirs 3 --connect-timeout 10 --max-time 60 --output "$RUNNER_TEMP/age.tar.gz" https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz
printf '%s %s\n' cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 "$RUNNER_TEMP/age.tar.gz" | sha256sum --check --status
tar -xzf "$RUNNER_TEMP/age.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/age" >> "$GITHUB_PATH"

- name: Validate repository
run: scripts/test.sh

Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/dns-snapshot.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: DNS snapshot (encrypted, read-only)

"on":
workflow_dispatch:
inputs:
confirmation:
description: Type EXPORT_ENCRYPTED_DNS after approving the encrypted artifact transport
required: true
type: string

permissions:
contents: read

concurrency:
group: encrypted-dns-snapshot
cancel-in-progress: false

jobs:
snapshot:
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- name: Require the approved operator, main and explicit export
env:
CONFIRMATION: ${{ inputs.confirmation }}
run: |
if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [ "$GITHUB_REF" != refs/heads/main ] || [ "$GITHUB_REPOSITORY" != devantler-tech/cloudflare ] || [ "$GITHUB_ACTOR" != devantler ] || [ "$GITHUB_RUN_ATTEMPT" != 1 ] || [ "$CONFIRMATION" != EXPORT_ENCRYPTED_DNS ]; then
echo 'Encrypted DNS export refused: use the approved operator, main and exact confirmation on a first attempt.' >&2
exit 2
fi

- name: Check out the confirmed revision
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Install the pinned encryption tool
run: |
curl --disable --fail --silent --show-error --proto '=https' --location --max-redirs 3 --connect-timeout 10 --max-time 60 --output "$RUNNER_TEMP/age.tar.gz" https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz
printf '%s %s\n' cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 "$RUNNER_TEMP/age.tar.gz" | sha256sum --check --status
tar -xzf "$RUNNER_TEMP/age.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/age" >> "$GITHUB_PATH"

- name: Read, validate and encrypt the owned DNS records
env:
CONFIRMATION: ${{ inputs.confirmation }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RECIPIENT_FILE: .github/dns-recovery-recipient.txt
OUTPUT_DIR: ${{ runner.temp }}/cloudflare-export
run: bash scripts/export-dns-snapshot.sh

- name: Export only the encrypted snapshot
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: encrypted-dns-snapshot
path: ${{ runner.temp }}/cloudflare-export/dns-snapshot.json.age
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: false
include-hidden-files: false
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ You need:

- [OpenTofu](https://opentofu.org/docs/intro/install/) on the 1.12 line
(`versions.tf` requires `~> 1.12.0`; CI runs 1.12.6);
- [ShellCheck](https://github.com/koalaman/shellcheck#installing) and `jq`;
- [ShellCheck](https://github.com/koalaman/shellcheck#installing), `jq`, and
[age](https://github.com/FiloSottile/age) (`age` and `age-keygen` on `PATH`;
CI verifies the pinned 1.3.2 release archive before using it);
- a POSIX shell; and
- network access to `registry.opentofu.org` and to GitHub's release downloads,
where OpenTofu fetches the pinned Cloudflare provider on the first run.
Expand Down
33 changes: 32 additions & 1 deletion docs/runbooks/bootstrap-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The workflow never imports, plans, applies or changes provider resources.
This follows GitHub's [manual workflow controls](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow)
and [environment-variable input handling](https://docs.github.com/en/actions/reference/security/secure-use#use-an-intermediate-environment-variable).

- OpenTofu 1.12.x and `jq`.
- OpenTofu 1.12.x, `jq`, and `age` (to decrypt and check the DNS snapshot).
- A short-lived Cloudflare bootstrap token with only the permissions needed to
read and import the inventoried resources.
- Separate R2 S3 credentials scoped only to the state bucket and state object.
Expand All @@ -27,6 +27,37 @@ and [environment-variable input handling](https://docs.github.com/en/actions/ref
Export secrets through the operator's secure session. Do not place values in a
command, `.tfvars`, backend file, CI log, issue, or pull request.

## Encrypted DNS inventory

The **DNS snapshot (encrypted, read-only)** workflow is disabled until a manual
dispatch by the maintainer on `main` with `EXPORT_ENCRYPTED_DNS`. Before its
first real dispatch, obtain explicit approval for the transport: this is a
public repository, so its short-lived Actions artifact is downloadable by
other signed-in GitHub users. Only authenticated age ciphertext is uploaded;
the matching dedicated private identity stays in private operator custody.
The public recipient is reviewed in `.github/dns-recovery-recipient.txt`.
Never dispatch unless that identity is recoverable and the destination is
approved. The workflow rejects reruns; a fresh snapshot needs a fresh dispatch.

This makes only fixed GET requests for the owned zone and its DNS records.
It verifies pagination counts, zone membership and record IDs, and preserves
all returned record fields. Missing access, partial pages, changing counts,
duplicates or invalid responses fail without an export. Pagination cannot
lock live DNS: retain a quiet change window and compare again before import.
This is a DNS snapshot, not a full account inventory or independent state.

Download the single `dns-snapshot.json.age` from the completed run into the
approved private recovery folder. Decrypt privately with the dedicated
identity, check the source revision, owned-zone binding and complete records,
then confirm the encrypted recovery copy is readable before deleting the
Actions artifact. Retention is capped at one day; it is transport, never the
recovery store. Do not print plaintext, account details or identity material.
Temporary local custody requires a verified transfer to permanent independent
custody before cleanup; do not delete the only recoverable copy.

Existing import, state, no-destroy plan and first-apply approval requirements
below remain in effect. A completed snapshot does not enable an apply.

## First migration

1. Inventory zones, records, buckets, lifecycle and lock settings, members, and
Expand Down
83 changes: 83 additions & 0 deletions scripts/export-dns-snapshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Private, read-only inventory. Only authenticated ciphertext leaves scratch.
set +x
set -euo pipefail
umask 077

stop() { printf 'DNS_SNAPSHOT=%s\n' "$1" >&2; exit 2; }

[[ ${GITHUB_EVENT_NAME:-} == workflow_dispatch && ${GITHUB_REF:-} == refs/heads/main \
&& ${GITHUB_REPOSITORY:-} == devantler-tech/cloudflare && ${GITHUB_ACTOR:-} == devantler \
&& ${GITHUB_RUN_ATTEMPT:-} == 1 && ${GITHUB_SHA:-} =~ ^[a-f0-9]{40}$ \
&& ${CONFIRMATION:-} == EXPORT_ENCRYPTED_DNS ]] || stop DISPATCH_REFUSED
[[ -n ${CLOUDFLARE_API_TOKEN:-} ]] || stop MISSING_CREDENTIAL
provider_token=$CLOUDFLARE_API_TOKEN
unset CLOUDFLARE_API_TOKEN
[[ ${#provider_token} -ge 20 && ${#provider_token} -le 512 && $provider_token =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || stop INVALID_CREDENTIAL_FORMAT
for tool in jq curl age; do command -v "$tool" >/dev/null 2>&1 || stop TOOLS_UNAVAILABLE; done
[[ -n ${OUTPUT_DIR:-} && $OUTPUT_DIR == /* && ! -e $OUTPUT_DIR && ! -L $OUTPUT_DIR \
&& -f ${RECIPIENT_FILE:-} && ! -L $RECIPIENT_FILE ]] || stop OUTPUT_OR_RECIPIENT_REFUSED
recipient=$(cat "$RECIPIENT_FILE")
[[ $recipient =~ ^age1[023456789acdefghjklmnpqrstuvwxyz]{58}$ ]] || stop RECIPIENT_REFUSED

scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
# Validate the native recipient before contacting the provider.
printf 'recipient validation\n' | age -r "$recipient" -o "$scratch/probe.age" 2>/dev/null || stop RECIPIENT_REFUSED

read_cloudflare() {
printf 'header = "Authorization: Bearer %s"\n' "$provider_token" |
curl --disable --config - --request GET --proto '=https' --noproxy '*' \
--max-redirs 0 --connect-timeout 10 --max-time 20 --max-filesize 8388608 \
--silent --fail --url "$1" 2>/dev/null
}

read_cloudflare 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50' > "$scratch/zone.json" || stop ZONE_READ_UNVERIFIED
zone_id=$(jq -er '
if .success == true and (.result|type) == "array" and (.result|length) == 1
and .result_info.total_count == 1 and .result_info.total_pages == 1
and .result[0].name == "devantler.tech" and .result[0].status == "active"
and (.result[0].id|type) == "string" and (.result[0].id|test("^[a-f0-9]{32}$"))
and (.result[0].account.id|type) == "string" and (.result[0].account.id|test("^[a-f0-9]{32}$"))
then .result[0].id else error("zone binding unavailable") end
' "$scratch/zone.json" 2>/dev/null) || stop ZONE_BINDING_UNVERIFIED

page=1 pages=1 total=0
while ((page <= pages)); do
read_cloudflare "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?per_page=1000&page=$page" > "$scratch/page-$page.json" || stop RECORD_READ_UNVERIFIED
jq -e --argjson page "$page" '
.success == true and (.result|type) == "array"
and .result_info.page == $page and .result_info.per_page == 1000
and (.result_info.total_count|type) == "number"
and (.result_info.total_count|floor) == .result_info.total_count
and .result_info.total_count > 0 and .result_info.total_count <= 10000
and .result_info.total_pages == ((.result_info.total_count / 1000)|ceil)
and .result_info.count == (.result|length)
and (.result|length) == ([1000, (.result_info.total_count - (($page-1)*1000))]|min)
and all(.result[];
(.id|type) == "string" and (.id|test("^[a-f0-9]{32}$"))
and (.name|type) == "string" and (.name == "devantler.tech" or (.name|endswith(".devantler.tech")))
and (.type|type) == "string" and (.type|length) > 0)
' "$scratch/page-$page.json" >/dev/null 2>&1 || stop PAGINATION_UNVERIFIED
if ((page == 1)); then
pages=$(jq -r .result_info.total_pages "$scratch/page-$page.json")
total=$(jq -r .result_info.total_count "$scratch/page-$page.json")
else
jq -e --argjson pages "$pages" --argjson total "$total" '
.result_info.total_pages == $pages and .result_info.total_count == $total
' "$scratch/page-$page.json" >/dev/null 2>&1 || stop INVENTORY_CHANGED
fi
page=$((page+1))
done
unset provider_token zone_id

jq -s '[.[].result[]]' "$scratch"/page-*.json > "$scratch/records.json" 2>/dev/null || stop INVENTORY_UNVERIFIED
jq -e --argjson total "$total" 'length == $total and ([.[].id]|unique|length) == $total' "$scratch/records.json" >/dev/null 2>&1 || stop DUPLICATE_OR_MISSING_RECORDS
jq -n --slurpfile zone "$scratch/zone.json" --slurpfile records "$scratch/records.json" \
--arg revision "$GITHUB_SHA" --arg captured "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
{schema:1, source_revision:$revision, captured_at:$captured, zone:$zone[0].result[0], records:$records[0]}
' > "$scratch/snapshot.json" 2>/dev/null || stop INVENTORY_UNVERIFIED
age -r "$recipient" -o "$scratch/snapshot.age" "$scratch/snapshot.json" 2>/dev/null || stop ENCRYPTION_FAILED
mkdir -m 700 "$OUTPUT_DIR" || stop OUTPUT_REFUSED
mv "$scratch/snapshot.age" "$OUTPUT_DIR/dns-snapshot.json.age"
printf '%s\n' 'DNS_SNAPSHOT=ENCRYPTED_COMPLETE' 'DEPLOYMENT_READINESS=UNVERIFIED'
1 change: 1 addition & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cd "$repo_root"
shellcheck scripts/*.sh scripts/tests/*.sh
scripts/tests/test-no-destructive-plan.sh
bash scripts/tests/test-cloudflare-connection.sh
bash scripts/tests/test-dns-snapshot.sh
tofu fmt -check -recursive
tofu init -backend=false -input=false
tofu validate
Loading
Loading