No2. Cloudflare Origin 存取控制:GCP Firewall 與 Web Server 來源限制

Cloudflare DNS 記錄設為 Proxied,只能讓正常的 hostname 解析結果指向 Cloudflare,不能強迫 Origin 拒絕其他連線。只要 GCP VM 的 80 或 443 仍對 0.0.0.0/0 開放,知道 Origin IP 的 client 就可能保留原 hostname,直接將 request 送到 Nginx 或 Apache2。

真正阻止 Origin direct access 的控制,是在 GCP Firewall 或 Web Server 明確執行下列規則:

Cloudflare CIDR → ALLOW
其他來源       → DENY

本文以一台執行 Ubuntu 24.04 的 GCP Compute Engine VM 為 Origin,並分別以 Nginx、Apache2 作為 Web Server 範例。能管理 GCP Firewall 時,由 Cloud Run Job 更新 Firewall rule,Cloud Scheduler 負責每日觸發,不在 Origin VM 執行 updater;無法控制 GCP Firewall 時,才改在 VM 上設定 Nginx 或 Apache2 來源限制。

實作範圍與 GCP VM 網路拓樸

實作環境簡化如下:

Cloud Scheduler ──每日觸發──> Cloud Run Job
                                  │
                                  │ 更新 sourceRanges
                                  ▼
                         GCP VPC Firewall

Visitor
   │
   ▼
Cloudflare Proxy
   │ Cloudflare source IP
   ▼
GCP VPC Firewall
   │ TCP 443
   ▼
Compute Engine VM
   └── Nginx 或 Apache2
項目 範例值 用途
GCP project PROJECT_ID Compute Engine 與 Firewall 所在專案
VPC network default VM 所屬網路;正式環境可使用自訂 VPC
VM cloudflare-origin-vm Origin 主機
Zone asia-east1-b VM 所在 zone
Region asia-east1 Cloud Run Job 與 Cloud Scheduler 區域
Network tag cf-origin 將 Firewall rule 限定到目標 VM
Origin IPv4 203.0.113.10 RFC 5737 文件位址,必須換成自有 VM IP
Hostname www.example.com 必須換成自有且已啟用 Proxy 的網域
HTTPS port 443 對外服務 port

主線只處理具有固定外部 IPv4 的 VM。只有網站確實提供 HTTP-to-HTTPS redirect 時才開放 80;沒有這項需求就只允許 443

控制點選擇

方案 Updater 執行位置 阻擋位置 適用條件
GCP VPC Firewall Cloud Run Job + Cloud Scheduler Request 抵達 VM 前 可以管理 GCP 網路與 IAM
Nginx allowdeny Origin VM + systemd timer Nginx access phase 無法調整 GCP Firewall,且使用 Nginx
Apache2 Require ip Origin VM + systemd timer Apache authorization phase 無法調整 GCP Firewall,且使用 Apache2

GCP Firewall 是較合適的主要方案:未授權封包不會進入 Web Server,也不受 Nginx real_ip 或 Apache2 mod_remoteip 改寫 client address 的影響。Cloud Run Job 與 Origin VM 分離,即使 VM 停機或重建,Firewall updater 仍能按排程同步 Cloudflare CIDR。Web Server 方案仍可達到來源限制,但 TCP 與 TLS 已經抵達 VM,且 updater 會隨 VM 停機而停止執行。

驗收基準

正常 Cloudflare 路徑必須維持可用,而且 request 必須確實抵達 Origin。測試時應選擇已設定為不快取的 health check endpoint,並使用唯一識別值:

ORIGIN_CHECK_ID="origin-check-$(date -u +%Y%m%dT%H%M%SZ)-${RANDOM}"

curl --noproxy '*' \
  --silent \
  --show-error \
  --dump-header - \
  --output /dev/null \
  "https://www.example.com/healthz?origin_check=${ORIGIN_CHECK_ID}"

printf 'Origin access log 應出現:%s\n' "$ORIGIN_CHECK_ID"

/healthz 必須換成實際存在且明確繞過 Cloudflare cache 的 endpoint。接著在 Origin VM 搜尋相同識別值;實際 log path 若已自訂,應使用目前 VirtualHost 的設定:

# Nginx
sudo grep --fixed-strings 'origin-check-REPLACE_WITH_ACTUAL_ID' \
  /var/log/nginx/access.log

# Apache2
sudo grep --fixed-strings 'origin-check-REPLACE_WITH_ACTUAL_ID' \
  /var/log/apache2/access.log

只有 HTTP response 成功而 access log 沒有這筆 request,不能證明 Cloudflare 已連到 Origin;response 可能來自 Cloudflare cache。

從一般網路指定自有 Origin IP 的連線則必須失敗:

curl --noproxy '*' \
  --verbose \
  --connect-timeout 5 \
  --resolve 'www.example.com:443:203.0.113.10' \
  --output /dev/null \
  'https://www.example.com/'

203.0.113.10 不可直接拿來測試,它只是文件 placeholder。實際驗證只能替換成自己管理或明確取得授權的 Origin IP。

上述指令同時受到 TCP、TLS 與 HTTP 三層結果影響。如果 Origin 使用 Cloudflare Origin CA certificate,一般 curl 可能因不信任簽發者而中止;這只能證明 client-side TLS 驗證失敗,不能證明 Nginx/Apache2 已執行來源限制。HTTP authorization 的獨立驗證方式會在「驗證與適用範圍」說明。

Full (strict) 不屬於本節的來源限制。它讓 Cloudflare 驗證 Origin certificate,並不驗證連入 Origin 的 client 是 Cloudflare;兩者需要分開設定與驗收。

既有網路與 Web Server 設定盤點

來源限制會直接影響網站可用性。建立新規則前,必須先確認 VM、Firewall target、服務 port 與管理路徑,尤其不能把網站的 80443 規則和 SSH 管理規則混在一起。

GCP 專案、VM 與 Network tag

以下指令在 Cloud Shell 或已登入正確帳戶的管理環境執行:

PROJECT_ID="$(gcloud config get-value project)"
VM_NAME='cloudflare-origin-vm'
VM_ZONE='asia-east1-b'
VPC_NETWORK='default'
REGION='asia-east1'

printf 'project=%s\nvm=%s\nzone=%s\nnetwork=%s\nregion=%s\n' \
  "$PROJECT_ID" "$VM_NAME" "$VM_ZONE" "$VPC_NETWORK" "$REGION"

PROJECT_ID 是 Firewall 所屬 GCP project;VM_NAME 與 VM_ZONE 用來定位 Origin VM;VPC_NETWORK 限制 rule 位於指定 VPC;REGION 則供 Cloud Run Job 與 Cloud Scheduler 使用。

查看 VM 的 NIC、外部 IP 與 network tag:

gcloud compute instances describe "$VM_NAME" \
  --project="$PROJECT_ID" \
  --zone="$VM_ZONE" \
  --format='yaml(
    name,
    status,
    networkInterfaces[].network,
    networkInterfaces[].accessConfigs[].natIP,
    tags.items
  )'

確認輸出的 External IPv4 就是 Cloudflare DNS 記錄背後的 Origin。若 VM 使用 ephemeral IP,重新啟動後可能改變;正式環境應先配置 Static External IP。

VPC Firewall 規則

gcloud compute firewall-rules list \
  --project="$PROJECT_ID" \
  --filter="network:$VPC_NETWORK" \
  --format='table(
    name,
    direction,
    priority,
    disabled,
    sourceRanges.list():label=SOURCE_RANGES,
    allowed[].map().firewall_rule().list():label=ALLOW,
    targetTags.list():label=TARGET_TAGS
  )'

上述指令只列出 classic VPC Firewall rules。若組織、資料夾或專案可能使用 Hierarchical Firewall Policy、Global Network Firewall Policy 或 Regional Network Firewall Policy,還要查看套用到 VM NIC 的 effective firewall rules:

gcloud compute instances network-interfaces get-effective-firewalls "$VM_NAME" \
  --project="$PROJECT_ID" \
  --zone="$VM_ZONE" \
  --network-interface='nic0' \
  --format='table(
    type,
    firewall_policy_name,
    priority,
    action,
    direction,
    ip_ranges.list():label=IP_RANGES,
    name,
    disabled
  )'

需要找出的不是只有名稱包含 http 或 https 的規則,而是所有可能允許下列流量的 rule:

  • 0.0.0.0/0 對 tcp:80tcp:443 開放。
  • 0.0.0.0/0 對所有 protocol/port 開放。
  • 套用到整個 VPC,或 target tag 同樣命中 Origin VM。
  • 更高優先序的 Hierarchical Firewall Policy 或 Network Firewall Policy。

GCP VPC Firewall 使用較小數字表示較高 priority;當沒有其他 ingress rule 符合時,VM 最終會落到 implied deny ingress。另一條仍有效的公開 ALLOW rule 會使 Cloudflare allowlist 失去限制效果,因此不能只確認新 rule 已建立。Google Cloud:VPC firewall rules

VM listener 與 VirtualHost

在 Origin VM 執行:

sudo ss --listening --numeric --tcp --process

Nginx:

sudo nginx -T
sudo nginx -t

Apache2:

sudo apache2ctl -S
sudo apache2ctl configtest

盤點內容至少包含:

– Web Server 實際監聽 `80`、`443`、`0.0.0.0` 或 `[::]` 的哪一組地址。

www.example.com 位於哪個 serverVirtualHost

– Default site 是否會把未知 hostname 送入正式 Application。

– 是否已啟用 Nginx real_ip 或 Apache2 mod_remoteip

– Health check、Webhook 或內部監控是否需要不經 Cloudflare 直連。

管理用途的固定來源不能混入自動產生的 Cloudflare 檔案,否則下次同步會被覆蓋。確有例外需求時,應建立獨立、人工維護的 Firewall rule 或 include,並記錄來源、port、用途與到期日。

Cloudflare IP API 與 CIDR 驗證

Cloudflare 提供不需要 API token 的 IP API:

curl --fail \
  --silent \
  --show-error \
  --connect-timeout 5 \
  --max-time 20 \
  'https://api.cloudflare.com/client/v4/ips'

回應中與本實作有關的欄位如下:

欄位 用途
success API request 是否成功
result.ipv4_cidrs Cloudflare IPv4 CIDR 陣列
result.ipv6_cidrs Cloudflare IPv6 CIDR 陣列
result.etag 清單內容識別值,便於 log 與變更判斷

Cloudflare 官方將 etag 定義為 IP 資料的 digest,並分別提供 IPv4、IPv6 清單:Cloudflare IP API。GCP 路線會確認 API 成功且清單非空,再交由 gcloud 更新;VM 上的 Web Server 路線則以 Python ipaddress 進一步驗證每一筆 CIDR。

GCP VPC Firewall 來源限制

GCP Firewall rule 屬於 VPC control plane,不是 VM 內的設定;停止 VM 不會刪除或停用 rule。需要與 VM 分離的是每日更新工作,因此本路線使用 Cloud Run Job,而不是在 Origin VM 執行 systemd updater。

GCP Firewall rule 應以 target network tag 限定到 Origin VM,避免同一 VPC 內其他 VM 的 443 一併受到影響。Google Cloud 說明 network tag 可以在 VM 運作中新增,而且 gcloud compute instances add-tags 會保留既有 tag。Google Cloud:Add network tags

VM Target tag

在 Cloud Shell 執行:

gcloud compute instances add-tags "$VM_NAME" \
  --project="$PROJECT_ID" \
  --zone="$VM_ZONE" \
  --tags='cf-origin'

gcloud compute instances describe "$VM_NAME" \
  --project="$PROJECT_ID" \
  --zone="$VM_ZONE" \
  --format='get(tags.items)'

輸出必須包含 cf-origin

Disabled allow rule

先以文件 CIDR 建立 disabled rule。Rule 尚未啟用,因此 placeholder 不會影響正式流量:

gcloud compute firewall-rules create allow-cloudflare-to-origin-v4 \
  --project="$PROJECT_ID" \
  --network="$VPC_NETWORK" \
  --direction=INGRESS \
  --priority=1000 \
  --action=ALLOW \
  --rules='tcp:443' \
  --source-ranges='192.0.2.0/24' \
  --target-tags='cf-origin' \
  --description='Managed by Cloudflare IP updater' \
  --disabled

各參數的作用如下:

參數 作用
--direction=INGRESS 控制進入 VM 的新連線
--priority=1000 Rule 評估順序;數字越小優先序越高
--action=ALLOW 只允許符合 source range 的流量
--rules=tcp:443 只開放 HTTPS
--source-ranges 第一輪同步前的暫存值
--target-tags=cf-origin 只套用到具有該 tag 的 VM
--disabled 完成同步及驗證前不生效

網站確實需要 port 80 時,將 --rules 改為:

--rules='tcp:80,tcp:443'

啟用 Cloud Run 與建置所需 API

後續操作都在 Cloud Shell 執行,不需要登入 Origin VM:

gcloud services enable \
  compute.googleapis.com \
  run.googleapis.com \
  cloudscheduler.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  --project="$PROJECT_ID"

Cloud Run Job service account

Cloud Run Job 使用獨立 service account。Custom Role 只提供讀取及更新 classic VPC Firewall rule 所需權限:

CLOUD_RUN_JOB='cloudflare-fw-sync'
UPDATER_SERVICE_ACCOUNT_NAME='cloudflare-fw-updater'
UPDATER_SERVICE_ACCOUNT="${UPDATER_SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
UPDATER_ROLE_ID='cloudflareFirewallUpdater'

gcloud iam service-accounts create "$UPDATER_SERVICE_ACCOUNT_NAME" \
  --project="$PROJECT_ID" \
  --display-name='Cloudflare Firewall Updater'

gcloud iam roles create "$UPDATER_ROLE_ID" \
  --project="$PROJECT_ID" \
  --title='Cloudflare Firewall Updater' \
  --description='Read and update classic VPC firewall rules' \
  --permissions='compute.firewalls.get,compute.firewalls.update' \
  --stage=GA

gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:${UPDATER_SERVICE_ACCOUNT}" \
  --role="projects/${PROJECT_ID}/roles/${UPDATER_ROLE_ID}"

compute.firewalls.update 是實際更新 rule 的權限,compute.firewalls.get 則用於變更前後比對。這組 project-level Custom Role 仍可更新 project 內其他 classic VPC Firewall rules,IAM 本身無法限定單一 rule 名稱;高隔離需求應使用獨立 project 或另外設計受控的管理服務。Google Cloud:firewalls.update

建立 Cloud Run Job 程式

在 Cloud Shell 建立工作目錄:

mkdir -p "$HOME/cloudflare-fw-sync"
cd "$HOME/cloudflare-fw-sync"
nano update.sh

update.sh 只處理必要流程:取得 Cloudflare 官方清單、確認 API 成功且 CIDR 陣列非空,再更新指定的 Firewall rule。

#!/usr/bin/env bash
set -Eeuo pipefail

: "${PROJECT_ID:?PROJECT_ID is required}"
: "${RULE_V4:?RULE_V4 is required}"
RULE_V6="${RULE_V6:-}"

API_RESPONSE="$(curl \
  --fail \
  --silent \
  --show-error \
  --connect-timeout 5 \
  --max-time 20 \
  --retry 3 \
  'https://api.cloudflare.com/client/v4/ips')"

jq --exit-status '.success == true' >/dev/null <<< "$API_RESPONSE"

CF_IPV4="$(jq --exit-status --raw-output '
  .result.ipv4_cidrs
  | if type == "array" and length > 0
    then join(",")
    else error("ipv4_cidrs is empty")
    end
' <<< "$API_RESPONSE")"

gcloud compute firewall-rules update "$RULE_V4" \
  --project="$PROJECT_ID" \
  --source-ranges="$CF_IPV4" \
  --quiet

if [[ -n "$RULE_V6" ]]; then
  CF_IPV6="$(jq --exit-status --raw-output '
    .result.ipv6_cidrs
    | if type == "array" and length > 0
      then join(",")
      else error("ipv6_cidrs is empty")
      end
  ' <<< "$API_RESPONSE")"

  gcloud compute firewall-rules update "$RULE_V6" \
    --project="$PROJECT_ID" \
    --source-ranges="$CF_IPV6" \
    --quiet
fi

echo 'Cloudflare Firewall sync completed.'

設定執行權限,再建立 Dockerfile

chmod 0755 update.sh
nano Dockerfile
FROM gcr.io/google.com/cloudsdktool/google-cloud-cli:stable

RUN apt-get update \
    && apt-get install --yes --no-install-recommends ca-certificates curl jq \
    && rm -rf /var/lib/apt/lists/* \
    && install -d -o 65532 -g 65532 /app

COPY --chown=65532:65532 update.sh /app/update.sh

USER 65532:65532
ENV CLOUDSDK_CONFIG=/tmp/gcloud

ENTRYPOINT ["/app/update.sh"]

:stable 適合快速建立範例;正式環境應將 base image 固定到明確版本或 digest,並定期更新。Google Cloud:Google Cloud CLI Docker image

部署並手動執行 Cloud Run Job

主線先只處理 IPv4:

執行部署的帳戶需要 Cloud Run Job 與 source build 的建立權限,也必須能以 UPDATER_SERVICE_ACCOUNT 作為 runtime service account;這是部署者權限,不要授予 Cloud Run Job 本身。

gcloud run jobs deploy "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --source=. \
  --service-account="$UPDATER_SERVICE_ACCOUNT" \
  --set-env-vars="PROJECT_ID=$PROJECT_ID,RULE_V4=allow-cloudflare-to-origin-v4" \
  --tasks=1 \
  --max-retries=1 \
  --task-timeout=300s

gcloud run jobs execute "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --wait

Cloud Run 使用指定的 service account 取得短效 credential,不需要 service account key JSON,也不需要在 Origin VM 安裝 gcloud--max-retries=1 代表失敗後可再執行一次;重複套用相同 sourceRanges 不會改變規則的最終內容。Google Cloud:Create jobs

IPv4 與 IPv6 使用兩條獨立 rule,也會產生兩次 update operation。若 IPv4 成功、IPv6 失敗,Cloud Run execution 會失敗;retry 會再次套用 IPv4,再嘗試 IPv6。

Cloud Run Job 第一次執行會把 disabled rule 的 placeholder 換成 Cloudflare 官方 IPv4 CIDR。確認後再啟用:

gcloud compute firewall-rules describe allow-cloudflare-to-origin-v4 \
  --project="$PROJECT_ID" \
  --format='yaml(disabled,sourceRanges,allowed,targetTags)'

gcloud compute firewall-rules update allow-cloudflare-to-origin-v4 \
  --project="$PROJECT_ID" \
  --no-disabled

Cloudflare hostname 在切換前的成功,只能證明網站原本可用,不能證明新 allow rule 已經命中,因為舊的公開 rule 此時仍在放行。確認新 rule 已啟用後,停用原本對全 Internet 開放的 HTTPS rule:

OPEN_HTTPS_RULE='REPLACE_WITH_EXISTING_RULE_NAME'

gcloud compute firewall-rules update "$OPEN_HTTPS_RULE" \
  --project="$PROJECT_ID" \
  --disabled

停用後,使用「驗收基準」中的不快取 endpoint 與唯一識別值重新測試。只有 Cloudflare path 成功,而且 Origin access log 出現相同識別值,才能證明 Cloudflare 流量通過新 rule 抵達 Origin。再列出所有 effective firewall rules,確認沒有另一條 0.0.0.0/0 → tcp:443 或 ALLOW all 命中同一 VM。GCP 的 allowlist 是所有 matching rules 的綜合結果,不是只看 allow-cloudflare-to-origin-v4 一條 rule。

Cloud Scheduler 每日觸發

建立只負責觸發 Cloud Run Job 的 service account,並將 roles/run.invoker 限定在這個 Job:

SCHEDULER_SERVICE_ACCOUNT_NAME='cloudflare-fw-scheduler'
SCHEDULER_SERVICE_ACCOUNT="${SCHEDULER_SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
SCHEDULER_JOB='cloudflare-fw-daily-sync'

gcloud iam service-accounts create "$SCHEDULER_SERVICE_ACCOUNT_NAME" \
  --project="$PROJECT_ID" \
  --display-name='Cloudflare Firewall Scheduler'

gcloud run jobs add-iam-policy-binding "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --member="serviceAccount:${SCHEDULER_SERVICE_ACCOUNT}" \
  --role='roles/run.invoker'

建立每天台灣時間 03:15 執行的 Scheduler job:

gcloud scheduler jobs create http "$SCHEDULER_JOB" \
  --project="$PROJECT_ID" \
  --location="$REGION" \
  --schedule='15 3 * * *' \
  --time-zone='Asia/Taipei' \
  --uri="https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/jobs/${CLOUD_RUN_JOB}:run" \
  --http-method=POST \
  --oauth-service-account-email="$SCHEDULER_SERVICE_ACCOUNT"

Cloud Scheduler 官方支援使用上述 Cloud Run Jobs API endpoint 定時執行 Job:Google Cloud:Execute jobs on a schedule。建立後先手動觸發並查看 Cloud Run execution:

gcloud scheduler jobs run "$SCHEDULER_JOB" \
  --project="$PROJECT_ID" \
  --location="$REGION"

gcloud run jobs executions list \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --job="$CLOUD_RUN_JOB" \
  --limit=5

gcloud run jobs logs read "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --limit=50

Scheduler 顯示成功,只代表 Cloud Run Jobs API 接受觸發請求;Firewall 是否同步成功,仍要以 Cloud Run execution 狀態、Job log 與 live source ranges 判斷。這套排程不依賴 Origin VM,因此 VM 停機期間仍會持續執行。

VM Web Server allowlist 共用元件

本節只適用於無法管理 GCP Firewall、改由 Nginx 或 Apache2 限制來源的環境。採用 Cloud Run Job 管理 GCP Firewall 時,不需要在 Origin VM 安裝這些 updater 元件。

共用工具與目錄

sudo apt update
sudo apt install --yes curl diffutils python3 util-linux

sudo install -d -m 0755 /usr/local/libexec

curl 負責 HTTPS request 與 timeout/retry,Python 標準函式庫的 ipaddress 負責 CIDR 驗證,cmp 用來判斷設定是否改變,flock 則由 util-linux 提供,用來防止 systemd 工作重疊。

CIDR Fetcher

建立檔案:

sudo nano /usr/local/libexec/fetch-cloudflare-cidrs

內容如下:

#!/usr/bin/env bash
set -Eeuo pipefail

umask 022

if [[ "$#" -ne 1 ]]; then
  echo "Usage: $0 OUTPUT_DIRECTORY" >&2
  exit 64
fi

OUTPUT_DIRECTORY="$1"
API_URL='https://api.cloudflare.com/client/v4/ips'

case "$OUTPUT_DIRECTORY" in
  /*) ;;
  *)
    echo 'OUTPUT_DIRECTORY must be an absolute path.' >&2
    exit 64
    ;;
esac

WORK_DIRECTORY="$(mktemp -d)"

cleanup() {
  rm -rf -- "$WORK_DIRECTORY"
}
trap cleanup EXIT

curl \
  --fail \
  --silent \
  --show-error \
  --location \
  --connect-timeout 5 \
  --max-time 20 \
  --retry 3 \
  --retry-delay 2 \
  --retry-all-errors \
  --output "$WORK_DIRECTORY/response.json" \
  "$API_URL"

python3 - "$WORK_DIRECTORY/response.json" "$WORK_DIRECTORY" <<'PY'
import ipaddress
import json
import pathlib
import sys

response_path = pathlib.Path(sys.argv[1])
output_path = pathlib.Path(sys.argv[2])

try:
    payload = json.loads(response_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
    raise SystemExit(f"Invalid Cloudflare API response: {exc}")

if not isinstance(payload, dict) or payload.get("success") is not True:
    raise SystemExit("Cloudflare API did not return success=true")

result = payload.get("result")
if not isinstance(result, dict):
    raise SystemExit("Cloudflare API result is missing")


def canonical_networks(field: str, version: int) -> list[str]:
    raw_networks = result.get(field)
    if not isinstance(raw_networks, list) or not raw_networks:
        raise SystemExit(f"{field} is empty or is not an array")
    if len(raw_networks) > 256:
        raise SystemExit(f"{field} contains an unexpected number of entries")

    networks = set()
    for value in raw_networks:
        if not isinstance(value, str):
            raise SystemExit(f"{field} contains a non-string value")
        try:
            network = ipaddress.ip_network(value, strict=True)
        except ValueError as exc:
            raise SystemExit(f"Invalid CIDR in {field}: {value}: {exc}")
        if network.version != version:
            raise SystemExit(f"Wrong IP version in {field}: {value}")
        networks.add(network)

    ordered = sorted(
        networks,
        key=lambda item: (int(item.network_address), item.prefixlen),
    )
    return [str(item) for item in ordered]


ipv4_networks = canonical_networks("ipv4_cidrs", 4)
ipv6_networks = canonical_networks("ipv6_cidrs", 6)
etag = result.get("etag", "")

if not isinstance(etag, str):
    raise SystemExit("etag is not a string")

(output_path / "ipv4.txt").write_text(
    "\n".join(ipv4_networks) + "\n",
    encoding="utf-8",
)
(output_path / "ipv6.txt").write_text(
    "\n".join(ipv6_networks) + "\n",
    encoding="utf-8",
)
(output_path / "etag").write_text(etag + "\n", encoding="utf-8")
PY

install -d -m 0755 "$OUTPUT_DIRECTORY"
install -m 0644 "$WORK_DIRECTORY/ipv4.txt" "$OUTPUT_DIRECTORY/ipv4.txt"
install -m 0644 "$WORK_DIRECTORY/ipv6.txt" "$OUTPUT_DIRECTORY/ipv6.txt"
install -m 0644 "$WORK_DIRECTORY/etag" "$OUTPUT_DIRECTORY/etag"

printf 'Validated Cloudflare CIDRs: IPv4=%s IPv6=%s etag=%s\n' \
  "$(wc -l < "$OUTPUT_DIRECTORY/ipv4.txt")" \
  "$(wc -l < "$OUTPUT_DIRECTORY/ipv6.txt")" \
  "$(cat "$OUTPUT_DIRECTORY/etag")"

設定權限並手動驗證:

sudo chown root:root /usr/local/libexec/fetch-cloudflare-cidrs
sudo chmod 0755 /usr/local/libexec/fetch-cloudflare-cidrs

CF_TEST_DIRECTORY='/run/cloudflare-ip-test'
sudo install -d -m 0755 "$CF_TEST_DIRECTORY"
sudo /usr/local/libexec/fetch-cloudflare-cidrs "$CF_TEST_DIRECTORY"
sudo cat "$CF_TEST_DIRECTORY/ipv4.txt"
sudo cat "$CF_TEST_DIRECTORY/ipv6.txt"
sudo rm -r -- "$CF_TEST_DIRECTORY"

只有 HTTP request、JSON schema、非空清單、IP version 與 canonical CIDR 全部通過檢查,fetcher 才會產生輸出;任何錯誤都以非零 exit code 結束,不會修改現有 Nginx 或 Apache2 設定。

Nginx 來源限制

無法管理 GCP Firewall 時,可以在 Nginx 的 server context 使用 allowdeny。Nginx 會依設定順序比對,第一條符合的規則就決定結果;因此 Cloudflare allow 必須放在 deny all 前面。NGINX:HTTP Access module

Nginx allowlist updater

建立 updater:

sudo nano /usr/local/sbin/update-cloudflare-nginx-allowlist
#!/usr/bin/env bash
set -Eeuo pipefail

umask 077

TARGET_FILE='/etc/nginx/snippets/cloudflare-origin-allowlist.conf'
FETCHER='/usr/local/libexec/fetch-cloudflare-cidrs'
WORK_DIRECTORY="$(mktemp -d)"

cleanup() {
  rm -rf -- "$WORK_DIRECTORY"
}
trap cleanup EXIT

"$FETCHER" "$WORK_DIRECTORY/candidate"

{
  echo '# Generated from the Cloudflare IP API. Do not edit.'
  while IFS= read -r cidr; do
    printf 'allow %s;\n' "$cidr"
  done < "$WORK_DIRECTORY/candidate/ipv4.txt"
  while IFS= read -r cidr; do
    printf 'allow %s;\n' "$cidr"
  done < "$WORK_DIRECTORY/candidate/ipv6.txt"
} > "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf"

install -d -m 0755 /etc/nginx/snippets
if [[ -f "$TARGET_FILE" ]] \
  && cmp --silent "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf" "$TARGET_FILE"; then
  echo 'No change: Nginx Cloudflare allowlist'
  exit 0
fi

install -m 0644 \
  "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf" \
  "$TARGET_FILE"

if ! nginx -t; then
  echo 'Nginx config test failed; reload skipped.' >&2
  exit 1
fi

if ! systemctl reload nginx; then
  echo 'Nginx reload failed.' >&2
  exit 1
fi

echo 'Updated and reloaded Nginx Cloudflare allowlist.'

設定權限並先產生 include:

sudo chown root:root /usr/local/sbin/update-cloudflare-nginx-allowlist
sudo chmod 0755 /usr/local/sbin/update-cloudflare-nginx-allowlist
sudo /usr/local/sbin/update-cloudflare-nginx-allowlist

Nginx server 設定

在 www.example.com 的 HTTPS server block 加入:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name www.example.com;

    include /etc/nginx/snippets/cloudflare-origin-allowlist.conf;
    deny all;

    # 既有 TLS、root 或 proxy_pass 設定保留於此。
}

若 VM 沒有 public IPv6,可以不使用 listen [::]:443 ssl;。若 Web Server 確實透過 public IPv6 對外,Cloudflare IPv6 allowlist 與 GCP IPv6 Firewall 都必須同步處理。

執行設定檢查與 reload:

sudo nginx -t
sudo systemctl reload nginx

allowdeny 設在 server context 時,只有下層 location 沒有自己的 allowdeny 才會繼承。既有設定若在某個 location 重新宣告 access rule,必須確認它沒有覆蓋 Cloudflare 限制。

Default server 也不能把未知 SNI/Host 送進正式 Application。應使用獨立 default server 拒絕未知 hostname;HTTPS default server 仍需沿用有效的 TLS 設定,不能只貼一個缺少 certificate 的不完整 listen 443 ssl block。

Apache2 來源限制

無法管理 GCP Firewall 且使用 Apache2 時,才在 VirtualHost 的 authorization phase 套用 Cloudflare 來源限制。

Apache 2.4 使用 Require ip 控制來源,不使用已 deprecated 的 OrderAllowDenyRequire ip 支援 IPv4、IPv6 與 CIDR,並在 authorization phase 對所有 HTTP method 生效。Apache HTTP Server:mod_authz_host

Apache2 allowlist updater

建立 updater:

sudo nano /usr/local/sbin/update-cloudflare-apache-allowlist
#!/usr/bin/env bash
set -Eeuo pipefail

umask 077

TARGET_FILE='/etc/apache2/cloudflare-origin-allowlist.conf'
FETCHER='/usr/local/libexec/fetch-cloudflare-cidrs'
WORK_DIRECTORY="$(mktemp -d)"

cleanup() {
  rm -rf -- "$WORK_DIRECTORY"
}
trap cleanup EXIT

"$FETCHER" "$WORK_DIRECTORY/candidate"

{
  echo '# Generated from the Cloudflare IP API. Do not edit.'
  echo '<RequireAny>'
  while IFS= read -r cidr; do
    printf '    Require ip %s\n' "$cidr"
  done < "$WORK_DIRECTORY/candidate/ipv4.txt"
  while IFS= read -r cidr; do
    printf '    Require ip %s\n' "$cidr"
  done < "$WORK_DIRECTORY/candidate/ipv6.txt"
  echo '</RequireAny>'
} > "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf"

if [[ -f "$TARGET_FILE" ]] \
  && cmp --silent "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf" "$TARGET_FILE"; then
  echo 'No change: Apache2 Cloudflare allowlist'
  exit 0
fi

install -m 0644 \
  "$WORK_DIRECTORY/cloudflare-origin-allowlist.conf" \
  "$TARGET_FILE"

if ! apache2ctl configtest; then
  echo 'Apache2 config test failed; reload skipped.' >&2
  exit 1
fi

if ! systemctl reload apache2; then
  echo 'Apache2 reload failed.' >&2
  exit 1
fi

echo 'Updated and reloaded Apache2 Cloudflare allowlist.'

設定權限並產生第一份 include:

sudo chown root:root /usr/local/sbin/update-cloudflare-apache-allowlist
sudo chmod 0755 /usr/local/sbin/update-cloudflare-apache-allowlist
sudo /usr/local/sbin/update-cloudflare-apache-allowlist

Apache2 VirtualHost 設定

在 www.example.com 的 HTTPS VirtualHost 加入:

<VirtualHost *:443>
    ServerName www.example.com

    <Location "/">
        Include /etc/apache2/cloudflare-origin-allowlist.conf
    </Location>

    # 既有 TLS、DocumentRoot 或 ProxyPass 設定保留於此。
</VirtualHost>

檢查 VirtualHost 與設定語法:

sudo apache2ctl -S
sudo apache2ctl configtest
sudo systemctl reload apache2

Include 刻意放在目標 VirtualHost 的 <Location "/">,避免來源限制意外套用到同一台主機上的管理站台或其他 hostname。其他 <Location><Directory> 或 Proxy authorization 規則仍可能影響合併結果,正式切換前要逐一測試公開 endpoint 與受保護 endpoint。

Default VirtualHost 不應代理到正式 Application。若同一台 VM 有多個網站,需要分別決定哪些 hostname 只接受 Cloudflare,不能把一個 site 的 allowlist 當成整台 Apache2 已受保護。

VM Web Server IP 清單自動更新

這組 systemd 設定只供 VM 上的 Nginx/Apache2 fallback 使用,兩種 updater 擇一執行:

採用方案 systemd ExecStart 使用的程式
Nginx /usr/local/sbin/update-cloudflare-nginx-allowlist
Apache2 /usr/local/sbin/update-cloudflare-apache-allowlist

systemd service

建立 service:

sudo nano /etc/systemd/system/cloudflare-ip-update.service

以下範例使用 Nginx;採用 Apache2 時,只替換 ExecStart 最後一個路徑:

[Unit]
Description=Update Cloudflare Origin source allowlist
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/flock --nonblock /run/cloudflare-ip-update.lock /usr/local/sbin/update-cloudflare-nginx-allowlist
TimeoutStartSec=120
UMask=0022
PrivateTmp=true
NoNewPrivileges=true

工作以 root 執行,因為 updater 需要寫入 /etc 並 reload Nginx 或 Apache2。NoNewPrivileges=true 防止 updater 及其 child process 透過 setuid 等方式增加額外權限。

systemd timer

sudo nano /etc/systemd/system/cloudflare-ip-update.timer
[Unit]
Description=Daily Cloudflare IP allowlist update

[Timer]
OnCalendar=*-*-* 03:15:00 Asia/Taipei
Persistent=true
RandomizedDelaySec=30m
Unit=cloudflare-ip-update.service

[Install]
WantedBy=timers.target

Persistent=true 讓 VM 關機錯過排程後,在 timer 恢復時補跑一次;RandomizedDelaySec=30m 則將執行時間分散在 30 分鐘內,避免大量主機同時呼叫遠端 API。這兩個參數的行為可參考 systemd.timer(5)

Service 與 Timer 驗證

sudo systemd-analyze verify \
  /etc/systemd/system/cloudflare-ip-update.service \
  /etc/systemd/system/cloudflare-ip-update.timer

sudo systemctl daemon-reload
sudo systemctl start cloudflare-ip-update.service
sudo systemctl status cloudflare-ip-update.service --no-pager

sudo systemctl enable --now cloudflare-ip-update.timer
systemctl list-timers cloudflare-ip-update.timer

手動執行成功後才啟用 timer。檢查最近一次同步 log:

sudo journalctl \
  --unit=cloudflare-ip-update.service \
  --since='2 days ago' \
  --no-pager

正常 log 應區分三種結果:

  • Validated:API 與 CIDR 已通過驗證。
  • Updated and reloaded:內容已變更並成功套用。
  • No change:內容相同,沒有執行 Web Server reload。

API timeout、JSON 錯誤或空清單發生在套用前,因此不會修改現行設定。Nginx/Apache2 的 config test 失敗時不會 reload;service 會回報 failed,便於從 journal 查明原因。VM 停機期間 timer 不會執行;Persistent=true 只能在 VM 恢復後補跑,不等同 Cloud Run 路線的獨立排程。

驗證與適用範圍

完整驗證矩陣

測試 GCP Firewall 預期結果 Nginx/Apache2 預期結果
不快取 endpoint 經 Cloudflare,且 Origin log 出現 request ID 2xx、預期 3xx 或 Application 正常狀態 相同
一般 curl --resolve 從外部直連 Origin TCP timeout HTTP 403;使用 Origin CA 時可能先發生憑證錯誤
使用 --cacert 或受控的 --insecure 驗證 HTTP 授權 TCP timeout 必須回覆 HTTP 403
Updater 第一次執行 Cloud Run execution 更新 rule,再以 describe 確認 systemd service 更新 include、config test、reload
API 回傳無效 JSON/空清單 Cloud Run execution failed,live rule 不變 systemd service failed,現行 include 不變
Web Server config test 失敗 不適用 不 reload,systemd service 回報 failed
Origin VM 停機 Firewall rule 保留,Cloud Scheduler 照常觸發 timer 停止,開機後由 Persistent=true 補跑

正常路徑使用「驗收基準」中的不快取 endpoint,並確認 Origin access log 出現相同 request ID。

Direct Origin HTTP authorization negative test:

curl --noproxy '*' \
  --insecure \
  --verbose \
  --connect-timeout 5 \
  --resolve 'www.example.com:443:203.0.113.10' \
  --output /dev/null \
  --write-out '\nHTTP %{http_code}\n' \
  'https://www.example.com/'

這裡的 --insecure 只用於自己管理或已取得授權的 Origin negative test,目的是略過 client-side certificate validation,繼續觀察 Web Server 的 HTTP authorization 結果;不能用於正常監控或正常 Cloudflare path。若已保存對應的 Origin CA root,應優先改用 --cacert /path/to/origin-ca-root.pem;Origin 使用公有 CA certificate 時則可直接移除 --insecure

GCP Firewall 的 timeout 代表封包在 Web Server 前被丟棄;Nginx/Apache2 必須回覆 403,才能證明 request 已進入 HTTP processing,但被來源規則拒絕。一般 curl 的 Origin CA 憑證錯誤只證明測試端不信任簽發者,不能當成來源限制成功。兩種 allowlist 都能阻止 direct Origin access,防護層級並不相同。

IPv6

存在下列任一條件,就必須把 IPv6 納入同一份驗收:

  • VM NIC 具有 External IPv6。
  • DNS 存在直接指向 Origin 的 AAAA record。
  • Nginx 或 Apache2 監聽 [::]:80[::]:443,且該 listener 可從 Internet 抵達。

GCP 路線建立獨立 IPv6 rule:

gcloud compute firewall-rules create allow-cloudflare-to-origin-v6 \
  --project="$PROJECT_ID" \
  --network="$VPC_NETWORK" \
  --direction=INGRESS \
  --priority=1000 \
  --action=ALLOW \
  --rules='tcp:443' \
  --source-ranges='2001:db8::/32' \
  --target-tags='cf-origin' \
  --description='Managed by Cloudflare IP updater' \
  --disabled

在啟用 IPv6 rule 前,先把 rule 名稱加入 Cloud Run Job,然後手動執行並驗證:

gcloud run jobs update "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --update-env-vars='RULE_V6=allow-cloudflare-to-origin-v6'

gcloud run jobs execute "$CLOUD_RUN_JOB" \
  --project="$PROJECT_ID" \
  --region="$REGION" \
  --wait

gcloud compute firewall-rules describe allow-cloudflare-to-origin-v6 \
  --project="$PROJECT_ID" \
  --format='yaml(disabled,sourceRanges,allowed,targetTags)'

gcloud compute firewall-rules update allow-cloudflare-to-origin-v6 \
  --project="$PROJECT_ID" \
  --no-disabled

Cloud Run updater 驗證並換成 Cloudflare IPv6 CIDR 後,才能啟用這條 rule。Nginx 與 Apache2 updater 已同步產生 IPv4、IPv6 設定;沒有 public IPv6 時,即使檔案包含 IPv6 allowlist,也不會自行建立 IPv6 ingress。

Real IP address rewrite

Nginx allowdeny 與 Apache2 Require ip 會依 Web Server 當下認定的 client address 進行授權。啟用 Nginx real_ip 或 Apache2 mod_remoteip 後,該地址可能被改寫成真正訪客 IP,導致原本要比對 Cloudflare peer 的 allowlist 全部失敗,或因信任範圍錯誤而產生繞過。

GCP Firewall 不受 HTTP address rewrite 影響,因此仍是較穩定的控制點。必須同時在 Web Server 完成來源限制與訪客 IP 還原時,應以底層 connection peer 建立信任判斷,並在 access log 同時保留 peer IP 與 visitor IP。

控制邊界

Cloudflare 官方建議在 Origin 明確封鎖所有非 Cloudflare 或非可信來源流量,並將 IP allowlist 列為網路層保護方式;若需要密碼學上的來源驗證,應再使用 Authenticated Origin Pulls,或以 Cloudflare Tunnel 移除 public inbound path。Cloudflare:Protect your origin server

來源 allowlist 解決的是「誰能連進 Origin」,不處理下列問題:

  • 不會自動還原真正 visitor IP。
  • 不會驗證 Origin certificate。
  • 不會停止訂單、付款或寄信等 Application 副作用。
  • 不會取代帳號認證、授權與 server-side input validation。
  • Global Cloudflare IP allowlist 只能證明來源位於 Cloudflare 網段,不能證明 request 來自特定 Cloudflare account。

完成條件不是看到新規則存在,而是同時證明正常 Cloudflare path 可用、Direct Origin path 失敗、無其他公開 allow rule,並確認所選 updater 的失敗行為。GCP Firewall 路線要驗證 Cloud Scheduler 與 Cloud Run execution;Nginx/Apache2 路線則要驗證 systemd timer、config test、reload 與 VM reboot 後補跑。