在 Cloudflare 控制台创建自定义令牌时,账户、区域、用户三组权限下拉项加起来有 400 多个,逐个勾选到手酸,还容易漏勾。其实令牌创建页面背后就是一个 API——本文记录我把它完全脚本化的过程:从 Python 版改成 Git Bash 版,接连踩中 6007、400、1001、9109、6111 五个坑,最终一个脚本跑出包含 232 个权限项的「全功能」令牌。完整代码与排错思路都在文中,可直接抄走复用。
📑 目录h2
一、思路:控制台点到手酸,不如直接调 APIh2
需求很朴素:创建一个拥有所有编辑权限的功能令牌,不要遗漏任何一个下拉项。在控制台里意味着要对着几百个下拉框逐个点选,一次误触就得重来。而「创建令牌」页面本身就是在调 Cloudflare API,所以整件事可以翻译成四步:
- 拉权限组 —— 调
GET /user/tokens/permission_groups,一次拿到全部 413 个权限组 - 按作用域过滤 + 取最高权限 —— 账户级、区域级、用户级分开处理;同一下拉项里,有「编辑/Write」就取它,只有「读取/Read」就取读取(比如审计日志、分析这类天生只读的项),保证一项不漏
- 组装 policy —— 每条 policy 绑定一个资源范围和一批权限组,超上限就自动分块
- 一次性提交 ——
POST /user/tokens创建令牌
最终一次跑通的成绩单:232 个权限项、9 条 policy,覆盖账户级 + 区域级的全部可授予项,令牌值当场返回(只显示一次)。
二、动手前先搞懂三个概念h2
2.1 引导令牌:API 令牌不能凭空创建h3
想用 API 创建令牌,手里必须先有一个具备令牌管理权限的「引导令牌」,最小配置只需一行权限:
| 第一列 | 第二列 | 第三列 |
|---|---|---|
| 帐户 | API 令牌 | 编辑 |
如果创建的令牌还要包含用户级权限,引导令牌再加一行「用户 → API 令牌 → 编辑」。引导令牌本身权限不小,用完建议立即删除。
2.2 三大作用域:policy 的地基h3
Cloudflare 的权限组按资源作用域分成三类,它们在请求体里的 resources 资源键完全不同,不能合并进同一条 policy:
| 作用域 | 权限组过滤关键字 | resources 资源键 | 实测权限项数量 |
|---|---|---|---|
| 账户级 | com.cloudflare.api.account | {"com.cloudflare.api.account.*":"*"} | 181 项 |
| 区域级 | com.cloudflare.api.account.zone | {"com.cloudflare.api.account.zone.*":"*"} | 51 项 |
| 用户级 | com.cloudflare.api.user | {"com.cloudflare.api.user.<用户ID>":"*"} | 零星几项 |
另一个硬约束:单个令牌的 policy 总条数上限是 10 条(第五节坑二会讲到怎么撞上、怎么绕开)。所以脚本的分块策略是「按作用域各自成条、每条尽量装满、总数不超 10」。
2.3 账户 ID、用户 ID、成员 ID 是三个不同的东西h3
这是本次踩坑里最迷惑人的一环,先一次说清:
| 名称 | 在哪看 | 标识什么 |
|---|---|---|
| 账户 ID(Account ID) | 控制台页面 URL 里 | 一个 Cloudflare 账户(组织),一个用户可以属于多个账户 |
| 用户 ID(User ID) | 右上角头像 → 我的个人资料,页面底部 | 你这个登录人;com.cloudflare.api.user.<ID> 只认它 |
| 成员 ID(Member ID) | 账户成员列表 | 成员关系记录,本文用不到 |
资源键里的用户 ID 千万不能填成账户 ID,否则键值不匹配会直接报错。另外,想通过 GET /user 接口自动取用户 ID,引导令牌还得有「用户 → 用户详细信息 → 读取」权限——没有的话接口返回 9109,脚本只能跳过用户级(坑四详解)。
三、最终版脚本(Git Bash)h2
3.1 依赖h3
- Git Bash(自带
curl) - jq:Git Bash 默认没有,安装任选其一:
winget install jqlang.jq# 或手动下载 jq-windows-amd64.exe,改名为 jq.exe,# 放进 C:\Program Files\Git\usr\bin\,重开 Git Bash装完执行 jq --version 确认可用。
3.2 完整脚本h3
保存为 create_token.sh。这一版吸收了后面五个坑的全部修复:请求体写文件再 --data-binary 发送(防编码)、policy 自动分块(防超限)、EXCLUDE_REGEX 排除不可授予项(防 1001)、用户 ID 直接写死(绕开 /user 权限依赖)、if 代替 &&(防 set -e 意外退出):
#!/usr/bin/env bash# create_token.sh —— 在 Git Bash 中运行set -euo pipefailexport LANG=C.UTF-8 LC_ALL=C.UTF-8
: "${CF_BOOT_TOKEN:?请先 export CF_BOOT_TOKEN=引导令牌}"
TOKEN_NAME="full-edit-token" # 建议英文,创建后可在控制台改名INCLUDE_ACCOUNT=true # 包含账户级权限INCLUDE_ZONE=true # 包含区域(Zone)级权限INCLUDE_USER=true # 包含用户级权限USER_ID="你的用户ID" # 用户 ID(不是账户 ID),控制台头像→我的个人资料可查ALLOW_IPS='[]' # 例如 '["1.2.3.4/32"]',空数组为不限制EXPIRES_ON="" # 例如 "2027-01-01T00:00:00Z",空为永不过期MAX_POLICIES=10 # Cloudflare 单个令牌的 policy 上限EXCLUDE_REGEX="API Tokens?" # 排除不可授予的权限组,如 "API Tokens?|Billing|Registrar",空为不排除
API="https://api.cloudflare.com/client/v4"AUTH="Authorization: Bearer $CF_BOOT_TOKEN"
# 1. 拉取全部权限组GROUPS_JSON=$(curl -s -H "$AUTH" "$API/user/tokens/permission_groups")echo "$GROUPS_JSON" | jq -e '.success' >/dev/null \ || { echo "获取权限组失败:"; echo "$GROUPS_JSON"; exit 1; }echo "权限组总数: $(echo "$GROUPS_JSON" | jq '.result | length')"
# 2. 取某作用域下"每个下拉项的最高权限"的 id 列表best_ids() { local scope="$1" echo "$GROUPS_JSON" | jq -c --arg scope "$scope" --arg ex "$EXCLUDE_REGEX" ' [ .result[] | select(.scopes | index($scope)) | select(if $ex == "" then true else (.name | test($ex) | not) end) | . as $g | ($g.name | capture("^(?<base>.*?)\\s+(?<lvl>Read|Write|Edit)$")? // {base:$g.name, lvl:"Other"}) as $m | {base:$m.base, id:$g.id, rank:(if $m.lvl=="Write" or $m.lvl=="Edit" then 2 elif $m.lvl=="Read" then 1 else 3 end)} ] | group_by(.base) | map(max_by(.rank) | {id}) '}
# 3. 收集各作用域的权限项declare -a SCOPE_RES SCOPE_IDSadd_scope() { # $1=资源JSON $2=权限ID数组JSON local n n=$(echo "$2" | jq 'length') if [ "$n" -eq 0 ]; then echo "跳过空作用域: $1"; return 0; fi SCOPE_RES+=("$1"); SCOPE_IDS+=("$2")}
if [ "$INCLUDE_ACCOUNT" = true ]; then add_scope '{"com.cloudflare.api.account.*":"*"}' "$(best_ids com.cloudflare.api.account)"fi
if [ "$INCLUDE_ZONE" = true ]; then add_scope '{"com.cloudflare.api.account.zone.*":"*"}' "$(best_ids com.cloudflare.api.account.zone)"fi
if [ "$INCLUDE_USER" = true ]; then add_scope "{\"com.cloudflare.api.user.$USER_ID\":\"*\"}" "$(best_ids com.cloudflare.api.user)"fi
# 4. 自动计算每条 policy 的容量,使总条数 <= MAX_POLICIESN=${#SCOPE_RES[@]}[ "$N" -eq 0 ] && { echo "没有可提交的权限作用域"; exit 1; }for i in $(seq 0 $((N-1))); do echo "作用域 $((i+1)): $(echo "${SCOPE_IDS[$i]}" | jq 'length') 个权限项 ${SCOPE_RES[$i]}"done
if [ "$N" -gt "$MAX_POLICIES" ]; then echo "作用域数量已超过 policy 上限,请拆分令牌"; exit 1fi
CHUNK=1while :; do need=0 for i in $(seq 0 $((N-1))); do c=$(echo "${SCOPE_IDS[$i]}" | jq 'length') need=$(( need + (c + CHUNK - 1) / CHUNK )) done [ "$need" -le "$MAX_POLICIES" ] && break CHUNK=$((CHUNK+1)) [ "$CHUNK" -gt 1000 ] && { echo "无法满足上限"; exit 1; }doneecho "自动选定每条 policy 容量: $CHUNK(预计 $need 条 policy)"
# 5. 生成 policiesPOLICIES='[]'for i in $(seq 0 $((N-1))); do P=$(jq -cn --argjson res "${SCOPE_RES[$i]}" --argjson ids "${SCOPE_IDS[$i]}" --argjson n "$CHUNK" ' [ range(0; ($ids|length); $n) as $k | {effect:"allow", resources:$res, permission_groups:$ids[$k:$k+$n]} ]') POLICIES=$(jq -cn --argjson a "$POLICIES" --argjson b "$P" '$a + $b')done
echo "将提交 $(echo "$POLICIES" | jq 'length') 条 policy,共 $(echo "$POLICIES" | jq '[.[].permission_groups|length]|add') 个权限项"
# 6. 组装请求体(写入文件,避免 shell/编码问题)jq -n --arg name "$TOKEN_NAME" --argjson p "$POLICIES" \ --argjson ips "$ALLOW_IPS" --arg exp "$EXPIRES_ON" ' {name:$name, policies:$p} + (if ($ips|length)>0 then {condition:{"request.ip":{in:$ips}}} else {} end) + (if $exp != "" then {expires_on:$exp} else {} end)' > body.json
jq -e . body.json >/dev/null && echo "body.json 合法,大小: $(wc -c < body.json) 字节"
# 7. 创建令牌RESP=$(curl -s -X POST "$API/user/tokens" \ -H "$AUTH" -H "Content-Type: application/json; charset=utf-8" \ --data-binary @body.json)
if [ "$(echo "$RESP" | jq -r '.success')" = "true" ]; then echo "创建成功,请立即保存(仅显示一次):" echo "$RESP" | jq -r '.result.value' rm -f body.jsonelse echo "创建失败:" echo "$RESP" | jq '.errors' echo "请求体保留在 body.json 中以便排查" echo "提示:若报错与用户级权限有关,把 INCLUDE_USER 改为 false 后重试" exit 1fi3.3 脚本里的四个设计点h3
- 每个下拉项只取最高权限:jq 里用正则把权限组名剥掉
Read/Write/Edit后缀,按基础名分组后取档位最高的那个——Write/Edit 记 2 分、Read 记 1 分、无后缀的项记 3 分原样保留。这样「DNS 编辑」不会和「DNS 读取」重复占坑,天生只读的「审计日志」也能保住它的读取权限。 - 排除机制:
EXCLUDE_REGEX默认排除API Tokens,原因见坑三;以后遇到其他不可授予的组(账单、注册机构之类),往里追加即可。 - 自动分块:从容量 1 开始递增试算,找到「总 policy 条数 ≤ 10」的最小容量,分块最均匀,不会出现某条过肥。
- 失败时保留
body.json:报错时请求体原地保留,直接打开看就是最真实的排查现场;成功才删。
四、使用步骤h2
第 1 步:建引导令牌。 在控制台「创建令牌 → 创建自定义令牌」里勾好权限后复制令牌值:
| 第一列 | 第二列 | 第三列 |
|---|---|---|
| 帐户 | API 令牌 | 编辑 |
| 用户(可选) | API 令牌 | 编辑 |
第 2 步:填用户 ID。 控制台右上角头像 → 我的个人资料,页面底部抄下用户 ID,替换脚本里的 USER_ID。不需要用户级权限就改成 INCLUDE_USER=false。
第 3 步:运行。
export CF_BOOT_TOKEN='cfut_这里粘贴真实引导令牌'bash create_token.sh第 4 步:保存令牌。 成功时的输出长这样(令牌值只显示这一次,立即收进密码管理器):
权限组总数: 413作用域 1: 181 个权限项 {"com.cloudflare.api.account.*":"*"}作用域 2: 51 个权限项 {"com.cloudflare.api.account.zone.*":"*"}自动选定每条 policy 容量: 26(预计 9 条 policy)将提交 9 条 policy,共 232 个权限项body.json 合法,大小: 18871 字节创建成功,请立即保存(仅显示一次):cfut_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX五、踩坑实录:五个报错逐一击破h2
坑一:6007 Malformed JSON——Git Bash 的编码暗坑h3
第一次运行,权限组拉取正常(413 个)、policy 也生成了 12 条,提交却报:
[ { "code": 6007, "message": "Malformed JSON in request body" }]原因:-d "$BODY" 把含中文的 JSON 按系统代码页(GBK)编码发出,服务器按 UTF-8 解析,直接判非法。
解决三件套:
- 请求体先用 jq 写入文件(jq 输出必然是 UTF-8);
- 发送改用
--data-binary @body.json,原样传字节,不做任何转码; Content-Type头补上charset=utf-8;令牌名干脆用英文(full-edit-token),创建后可在控制台改名,一劳永逸。
顺手加一道 jq -e . body.json 自检,提交前就能发现 JSON 是否合法。修完这关,报错从「服务器看不懂」变成了「服务器看懂了但不答应」——这是好事。
坑二:400——policy 条数超上限h3
编码修好,新的报错更有信息量:
policies must have a length lower than or equal to 10原因:每条 policy 装 25 个权限组,233 个权限项被切成 11 条,超过 Cloudflare 单令牌 10 条 policy 的硬上限。而且账户级和区域级资源键不同、不能合并,各类作用域至少各占 1 条,余量比想象中紧。
解决:放弃固定分块大小,从 1 开始递增试算,找到满足「总条数 ≤ 10」的最小容量。233 项最终按每条 26 个分成 9 条,全部塞进上限以内。如果未来权限组更多、单条也报「权限组过多」,就把账户级和区域级拆成两个令牌分别创建。
坑三:1001——子令牌不允许管理令牌h3
{ "code": 1001, "message": "sub-token is not allowed to have permissions to manage other tokens"}原因:Cloudflare 的安全设计——通过 API 创建的子令牌,不允许再携带「API 令牌」类的管理权限,防止令牌自我繁殖。账户级的 API Tokens、用户级的对应项都在此列,这是硬限制,绕不过去。
解决:EXCLUDE_REGEX="API Tokens?" 一条正则同时匹配单复数,把它们从提交列表里剔除。其他不可授予的组报错时把名称追加进去即可,例如 "API Tokens?|Billing|Registrar"。可以先用这条命令确认要排除哪些:
curl -s -H "Authorization: Bearer $CF_BOOT_TOKEN" \ https://api.cloudflare.com/client/v4/user/tokens/permission_groups \ | jq -r '.result[] | select(.name|test("token";"i")) | .name'真需要令牌管理权限只有两条路:控制台手动勾选,或者 Global API Key 配合 X-Auth-Email / X-Auth-Key 请求头调用接口——后者权限太大,不建议长期使用。排除后权限项从 233 降到 232,脚本自动重新分块,一次通过。
坑四:9109——读不到用户 IDh3
{ "code": 9109, "message": "Unauthorized to access requested resource"}9109 的含义是「令牌有效,但对这个资源没有权限」,我在两个场景撞见它:
场景 A:脚本调 GET /user 自动取用户 ID。该接口要求引导令牌具备「用户 → 用户详细信息 → 读取」,引导令牌没勾,于是只能跳过用户级。两个办法:
- 推荐:控制台头像 → 我的个人资料,手动抄用户 ID 写进脚本的
USER_ID(本文最终版已内置); - 或者干脆
INCLUDE_USER=false——用户级只有「用户详细信息、通知」等零星几项,管理域名和账户资源完全够用。
场景 B:脚本第一步拉权限组就报 9109。这通常说明把刚创建的「全功能」令牌当成引导令牌用了——它恰恰被我们排除了 API 令牌权限,自然调不动令牌接口。换回有「帐户 → API 令牌 → 编辑」权限的引导令牌即可。
排查时先跑令牌验证,能快速分辨「令牌死了」还是「权限不够」:
curl -s -H "Authorization: Bearer $CF_BOOT_TOKEN" \ https://api.cloudflare.com/client/v4/user/tokens/verify | jq .返回 "status": "active" 说明令牌本身没问题,剩下的就是权限范围的事。
坑五:6111——请求头格式无效h3
{ "code": 6003, "message": "Invalid request headers", "error_chain": [ { "code": 6111, "message": "Invalid format for Authorization header" } ]}原因:6111 发生在鉴权之前,说明 Authorization 头后面的值根本不像个令牌。三种经典翻车:
- 把示例里的占位文字原样
export了进去(真的会发生); - 新开了终端窗口——
export只在当前窗口有效,脚本退出后变量就没了; - 复制令牌时混进了换行或空格。
排查两连:
echo "长度: ${#CF_BOOT_TOKEN}"echo "开头: ${CF_BOOT_TOKEN:0:5}"正常长度约 53 个字符、开头是 cfut_。怀疑有隐藏字符就清一遍再验证:
CF_BOOT_TOKEN=$(echo "$CF_BOOT_TOKEN" | tr -d '\r\n ')curl -s -H "Authorization: Bearer $CF_BOOT_TOKEN" \ https://api.cloudflare.com/client/v4/user/tokens/verify | jq .六、错误码速查表h2
把五连坑浓缩成一张表,下次对着报错直接查:
| 错误码 | 含义 | 常见原因 | 解决办法 |
|---|---|---|---|
| 6007 | 请求体非法 JSON | Git Bash 下含中文的 JSON 按 GBK 发出 | 写入 UTF-8 文件 + --data-binary,Content-Type 加 charset=utf-8,令牌名用英文 |
| 400 | 请求校验失败 | policy 超过 10 条上限 | 自动分块(本文脚本已内置);仍超限就拆成两个令牌 |
| 1001 | 子令牌不允许管理令牌 | 权限组里含 API Tokens 类 | EXCLUDE_REGEX 排除;真需要只能控制台手动建 |
| 9109 | 无权访问该资源 | 令牌缺对应接口权限,或用错了令牌 | 补引导令牌权限 / 手动填 USER_ID / 换回引导令牌 |
| 6111 | Authorization 头格式无效 | 占位符没替换、新终端没重新 export、混入空白字符 | echo ${#CF_BOOT_TOKEN} 检查,tr -d 清理后重新 export |
| 6003 | 请求头无效(6111 的外层) | 同 6111 | 同 6111 |
| 1000 | 令牌无效 | 令牌已被删除或复制不完整 | 重新创建引导令牌 |
七、安全须知h2
全权限令牌 ≈ 把账户钥匙交出去。
- 这枚令牌覆盖账单、域名、DNS 等几乎所有编辑权限,一旦泄露等于账户被完全控制。创建时强烈建议同时设置
ALLOW_IPS(来源 IP 白名单)和EXPIRES_ON(过期时间),别贪「永不过期」的省事。 - 令牌值只显示一次,收到立即进密码管理器;不要写进代码、不要提交进仓库、不要贴进任何对话或截图——所有在对话、截图、日志里出现过的令牌都应视为已泄露,立刻到控制台删除或滚动更换。
- 引导令牌按最小权限创建(只勾 API 令牌编辑那一行),用完即删,不要留着「下次再用」。
- 「子令牌不能管理令牌」是 Cloudflare 刻意的安全设计,别想着绕过;真有该需求就控制台手动建,或短期用 Global API Key 并及时轮换。
写在最后h2
回顾整条流水线:引导令牌 → 拉全量权限组 → 每个下拉项取最高权限 → 按作用域自动分块 → 请求体写文件防编码坑 → 一次性提交。从「一个一个勾到手酸」到「一条命令 30 秒出全功能令牌」,中间隔着的不是多高深的技术,而是把页面操作翻译成 API 的耐心,和五个报错一个个啃下来的排错记录。
我在实际跑通的这一轮里,用户级因为引导令牌权限不足被跳过,最终 232 项全是账户级 + 区域级——管理域名和账户资源完全够用;真需要用户级,按 2.3 节抄好用户 ID 填进去重跑就行。
以上脚本与排错过程均来自本机真实操作记录。如果你在别的错误码上卡住过,欢迎在评论区补充,我会陆续更新进速查表。
Comments