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/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2
with:
tofu_version: 1.12.6
tofu_wrapper: false # The YAML parser test passes an expression through console stdin.

- name: Validate repository
run: scripts/test.sh
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/cloudflare-connection.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Cloudflare connection (read-only)

"on":
workflow_dispatch:
inputs:
confirmation:
description: Type VERIFY_READ_ONLY to check the existing CI connection
required: true
type: string

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Require main and typed confirmation
env:
CONFIRMATION: ${{ inputs.confirmation }}
run: |
if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [ "$GITHUB_REF" != refs/heads/main ] || [ "$CONFIRMATION" != VERIFY_READ_ONLY ]; then
echo 'Read-only connection check refused: use main and the exact confirmation.' >&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: Verify existing Cloudflare connection
env:
CONFIRMATION: ${{ inputs.confirmation }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: bash scripts/check-cloudflare-connection.sh
12 changes: 12 additions & 0 deletions docs/runbooks/bootstrap-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ writes credentials or state into this repository.

## Prerequisites

The manual **Cloudflare connection (read-only)** workflow can check the existing
`CLOUDFLARE_API_TOKEN` CI binding before another credential handoff is requested.
Run it from `main` with confirmation `VERIFY_READ_ONLY`. Its guard runs before
checkout and credential use; it makes only fixed GET requests for the owned zone
and active account token, and prints capability verdicts without account data.
Missing or unverifiable access fails the check. Verified read access does not
establish state custody, import coverage or write permissions.
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`.
- A short-lived Cloudflare bootstrap token with only the permissions needed to
read and import the inventoried resources.
Expand Down
44 changes: 44 additions & 0 deletions scripts/check-cloudflare-connection.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Read-only connection check. Provider responses and credentials never reach logs.
set +x
set -euo pipefail

stop() {
printf 'CF_CONNECTION=%s\nDEPLOYMENT_READINESS=UNVERIFIED\n' "$1" >&2
exit 2
}

[[ ${GITHUB_EVENT_NAME:-} == workflow_dispatch && ${GITHUB_REF:-} == refs/heads/main && ${CONFIRMATION:-} == VERIFY_READ_ONLY ]] || stop DISPATCH_REFUSED
if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
stop TOOLS_UNAVAILABLE
fi
[[ -n ${CLOUDFLARE_API_TOKEN:-} ]] || stop MISSING_CREDENTIAL
[[ ${#CLOUDFLARE_API_TOKEN} -ge 20 && ${#CLOUDFLARE_API_TOKEN} -le 512 && $CLOUDFLARE_API_TOKEN =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || stop INVALID_CREDENTIAL_FORMAT

read_cloudflare() {
# The credential goes through stdin, never a process argument. Disabling
# curlrc, proxies and redirects keeps the fixed provider destination intact.
printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" |
curl --disable --config - --request GET --proto '=https' --noproxy '*' \
--max-redirs 0 --connect-timeout 10 --max-time 20 --max-filesize 1048576 \
--silent --show-error --fail-with-body --url "$1" 2>/dev/null
}

zone_response=$(read_cloudflare 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50') || stop ZONE_READ_UNVERIFIED
account_id=$(printf '%s' "$zone_response" | 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].account.id|type) == "string"
and (.result[0].account.id|test("^[a-f0-9]{32}$"))
then .result[0].account.id else error("owned zone binding unavailable") end
' 2>/dev/null) || stop ZONE_BINDING_UNVERIFIED
unset zone_response

token_response=$(read_cloudflare "https://api.cloudflare.com/client/v4/accounts/$account_id/tokens/verify") || stop TOKEN_READ_UNVERIFIED
printf '%s' "$token_response" | jq -e '
.success == true and .result.status == "active"
and (.result.id|type) == "string" and (.result.id|length) > 0 and (.result.id|length) <= 32
' >/dev/null 2>&1 || stop TOKEN_STATUS_UNVERIFIED
unset token_response account_id
printf '%s\n' 'CF_CONNECTION=READ_ACCESS_VERIFIED' 'DEPLOYMENT_READINESS=UNVERIFIED'
1 change: 1 addition & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ cd "$repo_root"

shellcheck scripts/*.sh scripts/tests/*.sh
scripts/tests/test-no-destructive-plan.sh
bash scripts/tests/test-cloudflare-connection.sh
tofu fmt -check -recursive
tofu init -backend=false -input=false
tofu validate
163 changes: 163 additions & 0 deletions scripts/tests/test-cloudflare-connection.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
mkdir -p "$work_dir/bin" "$work_dir/parse"
probe="$repo_root/scripts/check-cloudflare-connection.sh"
fixture_token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

cat > "$work_dir/bin/curl" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
printf 'attempt\n' >> "$CF_FIXTURE_ATTEMPTS_FILE"
[[ $1 == --disable ]] || exit 90
url=''
while [[ $# -gt 0 ]]; do
case $1 in
--disable|--silent|--show-error|--fail-with-body) shift ;;
--config) [[ $2 == - ]] || exit 91; shift 2 ;;
--request) [[ $2 == GET ]] || exit 92; shift 2 ;;
--proto) [[ $2 == '=https' ]] || exit 93; shift 2 ;;
--noproxy) [[ $2 == '*' ]] || exit 94; shift 2 ;;
--max-redirs) [[ $2 == 0 ]] || exit 95; shift 2 ;;
--connect-timeout|--max-time|--max-filesize) shift 2 ;;
--url) url=$2; shift 2 ;;
*) exit 96 ;;
esac
done
IFS= read -r header
[[ $header == 'header = "Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"' ]] || exit 97
printf '%s\n' "$url" >> "$CF_FIXTURE_CALLS_FILE"
if [[ $CF_FIXTURE_CASE == http-error ]]; then
printf '{"success":false,"errors":[{"code":10000,"message":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}]}'
exit 22
fi
if [[ $CF_FIXTURE_CASE == invalid-json ]]; then
printf 'not JSON'
exit 0
fi
case $url in
'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50')
case $CF_FIXTURE_CASE in
ambiguous) printf '{"success":true,"result":[{},{}],"result_info":{"total_count":2,"total_pages":1}}' ;;
partial-page) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":2,"total_pages":2}}' ;;
wrong-zone) printf '{"success":true,"result":[{"name":"other.test","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":1,"total_pages":1}}' ;;
invalid-account) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"../other"}}],"result_info":{"total_count":1,"total_pages":1}}' ;;
*) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":1,"total_pages":1}}' ;;
esac
;;
'https://api.cloudflare.com/client/v4/accounts/22222222222222222222222222222222/tokens/verify')
case $CF_FIXTURE_CASE in
inactive) printf '{"success":true,"result":{"id":"33333333333333333333333333333333","status":"disabled"}}' ;;
short-token-id) printf '{"success":true,"result":{"id":"3333","status":"active"}}' ;;
*) printf '{"success":true,"result":{"id":"33333333333333333333333333333333","status":"active"}}' ;;
esac
;;
*) exit 98 ;;
esac
STUB
chmod +x "$work_dir/bin/curl"

run_probe() {
local scenario=$1 expected_rc=$2 expected_calls=$3 token=$fixture_token
local ref=refs/heads/main event=workflow_dispatch confirmation=VERIFY_READ_ONLY probe_rc=0
local probe_path="$work_dir/bin:$PATH"
case $scenario in
absent-token) token='' ;;
wrong-ref) ref=refs/heads/unreviewed ;;
wrong-event) event=pull_request ;;
wrong-confirmation) confirmation='VERIFY_READ_ONLY"; exit 0; #' ;;
missing-tool) probe_path="$work_dir/bin" ;;
esac
: > "$work_dir/calls"
: > "$work_dir/attempts"
if [[ $scenario == inherited-trace ]]; then
set -- /bin/bash -x "$probe"
else
set -- /bin/bash "$probe"
fi
env PATH="$probe_path" CF_FIXTURE_CASE="$scenario" CF_FIXTURE_CALLS_FILE="$work_dir/calls" CF_FIXTURE_ATTEMPTS_FILE="$work_dir/attempts" \
CLOUDFLARE_API_TOKEN="$token" GITHUB_REF="$ref" GITHUB_EVENT_NAME="$event" \
CONFIRMATION="$confirmation" "$@" > "$work_dir/$scenario.out" 2>&1 || probe_rc=$?
[[ $probe_rc == "$expected_rc" ]] || { echo "FAIL $scenario: expected exit $expected_rc, got $probe_rc" >&2; exit 1; }
[[ $(wc -l < "$work_dir/calls" | tr -d ' ') == "$expected_calls" ]] || { echo "FAIL $scenario: unexpected provider request count" >&2; exit 1; }
[[ $(wc -l < "$work_dir/attempts" | tr -d ' ') == "$expected_calls" ]] || { echo "FAIL $scenario: an unsafe request was attempted" >&2; exit 1; }
if grep -Eq 'AAAAAAAAAAAA|222222222222|333333333333|Authorization:' "$work_dir/$scenario.out"; then
echo "FAIL $scenario: private request material reached output" >&2
exit 1
fi
}

run_probe success 0 2
run_probe inherited-trace 0 2
run_probe short-token-id 0 2
grep -Fq 'CF_CONNECTION=READ_ACCESS_VERIFIED' "$work_dir/success.out"
grep -Fq 'DEPLOYMENT_READINESS=UNVERIFIED' "$work_dir/success.out"
run_probe absent-token 2 0
run_probe wrong-ref 2 0
run_probe wrong-event 2 0
run_probe wrong-confirmation 2 0
run_probe missing-tool 2 0
run_probe http-error 2 1
run_probe invalid-json 2 1
run_probe ambiguous 2 1
run_probe partial-page 2 1
run_probe wrong-zone 2 1
run_probe invalid-account 2 1
run_probe inactive 2 2

# Reuse the project's required OpenTofu runtime as a real YAML parser; this
# empty, disposable module has no provider, backend or production credential.
workflow="$repo_root/.github/workflows/cloudflare-connection.yaml"
expression=$(jq -nr --arg path "$workflow" '"jsonencode(yamldecode(file(" + ($path|tojson) + ")))"')
printf '%s\n' "$expression" | tofu -chdir="$work_dir/parse" console -no-color > "$work_dir/workflow-string.json"
jq -r . "$work_dir/workflow-string.json" > "$work_dir/workflow.json"

check_workflow() {
jq -e '
.permissions == {contents:"read"}
and (.on|keys) == ["workflow_dispatch"]
and .on.workflow_dispatch.inputs.confirmation.type == "string"
and .on.workflow_dispatch.inputs.confirmation.required == true
and (.jobs|keys) == ["check"]
and .jobs.check["runs-on"] == "ubuntu-latest"
and .jobs.check["timeout-minutes"] == 3
and (.jobs.check.permissions // {}) == {}
and (.jobs.check|has("continue-on-error")|not)
and (.jobs.check|has("if")|not)
and all(.jobs.check.steps[]; (has("continue-on-error")|not) and (has("if")|not))
and (.env // {}) == {} and (.jobs.check.env // {}) == {}
and (.defaults // {}) == {} and (.jobs.check.defaults // {}) == {}
and (.jobs.check.steps|length) == 3
and .jobs.check.steps[0].env == {CONFIRMATION:"${{ inputs.confirmation }}"}
and (.jobs.check.steps[0].run|contains("${{")|not)
and .jobs.check.steps[1].uses == "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"
and .jobs.check.steps[1].with == {"persist-credentials":false,ref:"${{ github.sha }}"}
and (.jobs.check.steps[1].env // {}) == {}
and .jobs.check.steps[2].env == {CONFIRMATION:"${{ inputs.confirmation }}",CLOUDFLARE_API_TOKEN:"${{ secrets.CLOUDFLARE_API_TOKEN }}"}
and .jobs.check.steps[2].run == "bash scripts/check-cloudflare-connection.sh"
' "$1" >/dev/null
}
check_workflow "$work_dir/workflow.json"
jq -r '.jobs.check.steps[0].run' "$work_dir/workflow.json" > "$work_dir/guard.sh"
env GITHUB_REF=refs/heads/main GITHUB_EVENT_NAME=workflow_dispatch CONFIRMATION=VERIFY_READ_ONLY bash "$work_dir/guard.sh"
for scenario in wrong-ref wrong-event wrong-confirmation; do
ref=refs/heads/main; event=workflow_dispatch; confirmation=VERIFY_READ_ONLY
case $scenario in
wrong-ref) ref=refs/heads/unreviewed ;;
wrong-event) event=pull_request ;;
wrong-confirmation) confirmation='VERIFY_READ_ONLY"; exit 0; #' ;;
esac
if env GITHUB_REF="$ref" GITHUB_EVENT_NAME="$event" CONFIRMATION="$confirmation" bash "$work_dir/guard.sh" > /dev/null 2>&1; then
echo "FAIL inline guard accepted $scenario" >&2; exit 1
fi
done
for mutation in '.permissions.contents="write"' '.on.push={}' '.jobs.check.steps|=.[1:]' '.jobs.check.steps[0].env.CLOUDFLARE_API_TOKEN="exposed-before-guard"' '.jobs.check.steps[1].with.token="provider-key"'; do
jq "$mutation" "$work_dir/workflow.json" > "$work_dir/mutant.json"
if check_workflow "$work_dir/mutant.json"; then
echo 'FAIL unsafe workflow control accepted' >&2; exit 1
fi
done
printf '%s\n' 'PASS: read-only Cloudflare connection and workflow failure controls'
Loading