cloudflare-tunnel-publish

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

What this skill does

此技能的功能

Turns a service running on a local port (default: a Starchild preview, but works for any HTTP service) into something the world can reach at
app.userdomain.com
, using Cloudflare Tunnel. No public IP required, no inbound ports opened, free SSL.
Two roles in the flow:
  • User does manually (must, can't be automated): create Cloudflare account, buy/transfer domain to Cloudflare, create API Token.
  • Agent does automatically (this skill): verify token, pick zone, create tunnel, configure ingress, create DNS, install + start
    cloudflared
    , verify the public URL works.
借助Cloudflare Tunnel,将运行在本地端口的服务(默认是Starchild预览服务,但适用于任何HTTP服务)转换为可通过
app.userdomain.com
访问的公开服务。无需公网IP,无需开放入站端口,免费提供SSL证书。
流程中的两个角色:
  • 用户手动操作(必须,无法自动化):创建Cloudflare账户,购买/转移域名到Cloudflare,创建API Token。
  • Agent自动操作(此技能负责):验证Token,选择区域,创建隧道,配置入口规则,创建DNS记录,安装并启动
    cloudflared
    ,验证公开URL可正常访问。

Audience assumption

受众假设

Treat the user as a beginner. They may have never used Cloudflare. Walk them through one micro-step at a time, wait for confirmation, then move on. Do NOT dump the whole 10-step plan and disappear.
将用户视为初学者。他们可能从未使用过Cloudflare。请逐步引导他们完成每个微小步骤,等待确认后再继续。不要一次性抛出完整的10步计划后就结束对话。

Workflow

工作流程

Phase 0 — Set the stage (1 message)

阶段0 — 说明流程(1条消息)

Tell the user in plain language what's about to happen, in 4 phases:
  1. They register a Cloudflare account + add a domain (manual, ~5 min)
  2. They create an API Token and give it to you securely (manual, ~2 min)
  3. You build the tunnel + DNS + start it (automatic, ~1 min)
  4. You test the URL together (automatic)
Ask: "Do you already have a domain on Cloudflare, or do we need to start from scratch?" Branch on the answer.
用通俗易懂的语言告诉用户即将进行的4个阶段:
  1. 他们注册Cloudflare账户并添加域名(手动操作,约5分钟)
  2. 他们创建API Token并安全地提供给你(手动操作,约2分钟)
  3. 你构建隧道、配置DNS并启动服务(自动操作,约1分钟)
  4. 你们一起测试URL(自动操作)
询问:"你已经在Cloudflare上拥有域名了吗?还是我们需要从头开始?" 根据回答分支处理。

Phase 1 — Get the user a domain on Cloudflare

阶段1 — 帮助用户在Cloudflare上获取域名

If they don't have one yet:
Beginner hint to share: "When the domain shows status Active in your Cloudflare dashboard, we're good to continue."
If they already have one: skip to Phase 2.
如果用户还没有域名:
可分享给初学者的提示:"当你的Cloudflare控制台中域名状态显示为Active时,我们就可以继续了。"
如果用户已有域名:直接跳至阶段2。

Phase 2 — Create the API Token

阶段2 — 创建API Token

Send them this exact link (it pre-fills the right permissions when possible, and the user can also build it manually):
https://dash.cloudflare.com/profile/api-tokensCreate TokenCreate Custom Token
Required permissions (tell them to add these three):
  • AccountCloudflare TunnelEdit
  • ZoneDNSEdit
  • ZoneZoneRead
Account Resources: their account. Zone Resources: Include All zones (or specifically the domain). TTL: leave default.
After they click Continue to summaryCreate Token → Cloudflare shows the token once. Tell them: do not paste it in chat.
发送以下精确链接(它会预填充正确的权限,用户也可以手动配置):
https://dash.cloudflare.com/profile/api-tokensCreate TokenCreate Custom Token
所需权限(告诉用户添加以下三项):
  • AccountCloudflare TunnelEdit
  • ZoneDNSEdit
  • ZoneZoneRead
账户资源:用户的账户。区域资源:Include All zones(或指定对应的域名)。TTL:保留默认值。
用户点击Continue to summaryCreate Token后,Cloudflare会仅显示一次该Token。告诉用户:不要在聊天框中粘贴此Token

Phase 3 — Receive the token securely

阶段3 — 安全接收Token

Call
request_env_input
with:
env_vars=[{"key": "CLOUDFLARE_API_TOKEN", "label": "Cloudflare API Token", "required": true}]
reason="Used to create the tunnel and DNS record on your domain. Stored locally in workspace/.env, never echoed in chat."
Wait for the user to submit it via the secure popup. Do not retry-loop if they don't submit immediately — just wait.
调用
request_env_input
,参数如下:
env_vars=[{"key": "CLOUDFLARE_API_TOKEN", "label": "Cloudflare API Token", "required": true}]
reason="Used to create the tunnel and DNS record on your domain. Stored locally in workspace/.env, never echoed in chat."
等待用户通过安全弹窗提交Token。如果用户未立即提交,不要循环重试 — 只需等待即可。

Phase 4 — Verify token + pick the zone

阶段4 — 验证Token并选择区域

Run
python3 skills/cloudflare-tunnel-publish/scripts/verify.py
. It prints:
  • Token validity
  • The user's account_id (saves to
    workspace/.cf_state.json
    )
  • All zones (domains) on the account
If multiple zones, ask the user which domain to use. Save
zone_id
and
zone_name
to state.
运行
python3 skills/cloudflare-tunnel-publish/scripts/verify.py
。它会输出:
  • Token有效性
  • 用户的account_id(保存至
    workspace/.cf_state.json
  • 账户下的所有区域(域名)
如果存在多个区域,询问用户使用哪个域名。将
zone_id
zone_name
保存到状态文件中。

Phase 5 — Decide what to publish

阶段5 — 确定要发布的内容

Ask the user two things:
  1. Subdomain (e.g.,
    app
    ,
    demo
    ,
    www
    ) → final hostname will be
    <sub>.<zone_name>
    . Apex domain (
    @
    ) is also allowed.
  2. Local port (e.g.,
    8080
    ,
    3000
    ). If they say "my Starchild preview", run
    cat /data/previews.json 2>/dev/null
    to look up an existing preview's port; otherwise ask explicitly.
Default service URL:
http://localhost:<port>
.
询问用户两件事:
  1. 子域名(例如
    app
    demo
    www
    )→ 最终主机名将是
    <sub>.<zone_name>
    。也允许使用 apex 域名(
    @
    )。
  2. 本地端口(例如
    8080
    3000
    )。如果用户说“我的Starchild预览服务”,运行
    cat /data/previews.json 2>/dev/null
    查找现有预览服务的端口;否则直接询问具体端口。
默认服务URL:
http://localhost:<port>

Phase 6 — Build the tunnel (automated)

阶段6 — 构建隧道(自动化)

Run
python3 skills/cloudflare-tunnel-publish/scripts/setup.py --hostname <full_hostname> --port <port>
.
Tunnel reuse is the default. If a healthy tunnel already exists on the account,
setup.py
adds the new hostname to that tunnel's ingress and points the DNS CNAME at it — instead of creating a separate tunnel. One tunnel = one
cloudflared
process = one keepalive watchdog. Creating a new tunnel per site is how you end up with N processes to babysit and N ways to silently go dark (the exact bug that prompted this: a second tunnel's
cloudflared
died and nothing was watching it, because the watchdog only knew about the first).
Pass
--new-tunnel
only when you have a reason to isolate traffic (e.g. very high volume on one site that would saturate the shared edge connections).
The script does, in order:
  1. Reuse a healthy tunnel if one exists (or create one named
    starchild-<hostname>
    if none, or use
    --new-tunnel
    to force a new one)
  2. Fetch the tunnel run token (a long base64 string used to start
    cloudflared
    )
  3. PUT the ingress configuration: merge
    <hostname>
    http://localhost:<port>
    into the existing ingress rules (preserving other hostnames), fallback
    404
  4. Create a CNAME DNS record:
    <hostname>
    <tunnel_id>.cfargotunnel.com
    , proxied = true
  5. Append the site to the
    sites
    array in
    workspace/.cf_state.json
    (multi-site support — see "Multiple sites" below)
If a tunnel with the same name exists, reuse it instead of erroring.
运行
python3 skills/cloudflare-tunnel-publish/scripts/setup.py --hostname <full_hostname> --port <port>
默认复用隧道。如果账户中已存在健康的隧道,
setup.py
会将新主机名添加到该隧道的入口规则中,并将DNS CNAME指向该隧道 — 而不是创建单独的隧道。一个隧道对应一个
cloudflared
进程和一个保活监控。为每个站点创建新隧道会导致需要维护N个进程,并且存在N种静默故障的可能(促使开发此技能的正是这样的bug:第二个隧道的
cloudflared
进程崩溃,但监控只关注第一个隧道,因此无人察觉)。
仅当有理由隔离流量时才使用
--new-tunnel
参数(例如某个站点流量极高,会占用共享边缘连接的全部带宽)。
脚本按以下顺序执行:
  1. 如果存在健康隧道则复用(如果不存在则创建名为
    starchild-<hostname>
    的隧道,或使用
    --new-tunnel
    强制创建新隧道)
  2. 获取隧道运行Token(一个长base64字符串,用于启动
    cloudflared
  3. 更新入口配置:将
    <hostname>
    http://localhost:<port>
    合并到现有入口规则中(保留其他主机名),默认返回404
  4. 创建CNAME DNS记录:
    <hostname>
    <tunnel_id>.cfargotunnel.com
    proxied = true
  5. 将站点信息追加到
    workspace/.cf_state.json
    sites
    数组中(支持多站点 — 见下文“多站点”部分)
如果同名隧道已存在,则复用该隧道而非报错。

Phase 7 — Start cloudflared

阶段7 — 启动cloudflared

Run
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
— this is the canonical way to bring the site up.
keepalive.sh
is the single start+heal brain (see "Keeping it alive" below): it reads
.cf_state.json
, starts the app (if you recorded
--app-cmd
) and the tunnel, and verifies the public URL.
run_tunnel.sh
is the lower-level tunnel-only launcher that
keepalive.sh
calls — it downloads
cloudflared
to
workspace/bin/
if missing, reads the run_token from
.cf_state.json
, and runs
cloudflared tunnel run
.
Tell the user the site is up. The SAME
keepalive.sh
is what you'll wire into boot + a schedule for durability — don't hand-roll a separate starter.
运行
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
— 这是启动站点的标准方式。
keepalive.sh
是唯一的启动+自愈脚本(见下文“保持服务运行”部分):它读取
.cf_state.json
,启动应用(如果记录了
--app-cmd
)和隧道,并验证公开URL是否可访问。
run_tunnel.sh
keepalive.sh
调用的底层隧道启动器 — 如果
workspace/bin/
中没有
cloudflared
,它会自动下载,从
.cf_state.json
读取运行Token,然后执行
cloudflared tunnel run
告知用户站点已上线。同一个
keepalive.sh
将用于开机启动和定时任务以保证服务持久性 — 不要手动编写单独的启动脚本。

Phase 8 — Verify

阶段8 — 验证

⚠️ Do not use the container's
curl https://<hostname>
directly
— the container's resolver caches stale NXDOMAIN for new domains and will lie to you. Always verify via DoH:
bash
curl -sS "https://dns.google/resolve?name=<hostname>&type=A" | python3 -m json.tool
Three possible outcomes:
  1. Status: 0
    + IPs in
    Answer
    → live. Now
    curl -I https://<hostname>
    should return 200/301/302. Show the user their URL. 🎉
  2. Status: 3
    (NXDOMAIN) + Authority = TLD registry NS
    (e.g.
    ns.trs-dns.com
    ) → TLD registry hasn't propagated the new domain yet. Tell the user: configuration is 100% done, wait 30–60 min (newly registered domains can take up to 24 h), then retry. Don't keep polling — let them check on their own device.
  3. Tunnel logs show errors (check
    bash_process(action='log', session_id=...)
    ) → real config bug. Common culprits: ingress not pointing at the right port, local service not running, wrong CNAME target.
⚠️ 不要直接使用容器内的
curl https://<hostname>
进行验证
— 容器的解析器会缓存新域名的陈旧NXDOMAIN记录,导致结果不准确。始终通过DoH进行验证:
bash
curl -sS "https://dns.google/resolve?name=<hostname>&type=A" | python3 -m json.tool
可能的三种结果:
  1. Status: 0
    +
    Answer
    中包含IP地址
    → 服务已上线。此时
    curl -I https://<hostname>
    应返回200/301/302。将URL展示给用户。 🎉
  2. Status: 3
    (NXDOMAIN) + Authority = TLD注册商NS
    (例如
    ns.trs-dns.com
    )→ TLD注册商尚未完成新域名的传播。告知用户:配置已100%完成,请等待30–60分钟(新注册的域名可能需要长达24小时),然后重试。不要持续轮询 — 让用户在自己的设备上检查。
  3. 隧道日志显示错误(查看
    bash_process(action='log', session_id=...)
    )→ 存在实际配置错误。常见原因:入口规则未指向正确端口、本地服务未运行、CNAME目标错误。

Decision rules

决策规则

  • User says "my service is on my laptop, not in Starchild" → exact same flow, but Phase 7 must run on their laptop, not in this container. Give them the equivalent install command for their OS:
    • macOS:
      brew install cloudflared && cloudflared tunnel run --token <TOKEN>
    • Linux/Windows: link to https://github.com/cloudflare/cloudflared/releases/latest Send the run_token via
      request_env_input
      if needed, or just print it once and tell them to copy it (it's safe to share with their own machine, but never paste back to chat).
  • User wants multiple subdomains → reuse the same tunnel; PUT a new ingress config that lists all hostnames; create one CNAME per hostname. This is now the default behavior — just run
    setup.py
    for each subdomain and it will merge into the existing tunnel's ingress.
  • User wants to remove it → run
    python3 skills/cloudflare-tunnel-publish/scripts/teardown.py
    (deletes DNS + tunnel + kills the local cloudflared process).
  • 用户说“我的服务在我的笔记本电脑上,不在Starchild中” → 流程完全相同,但阶段7必须在用户的笔记本电脑上运行,而非此容器中。提供适用于其操作系统的等效安装命令:
    • macOS:
      brew install cloudflared && cloudflared tunnel run --token <TOKEN>
    • Linux/Windows: 链接至https://github.com/cloudflare/cloudflared/releases/latest 必要时通过
      request_env_input
      发送运行Token,或仅打印一次并告知用户复制(在用户自己的设备上分享是安全的,但不要粘贴回聊天框)。
  • 用户需要多个子域名 → 复用同一个隧道;更新入口配置以列出所有主机名;为每个主机名创建一个CNAME记录。这是当前默认行为 — 只需为每个子域名运行
    setup.py
    ,它会合并到现有隧道的入口规则中。
  • 用户希望移除服务 → 运行
    python3 skills/cloudflare-tunnel-publish/scripts/teardown.py
    (删除DNS记录、隧道并终止本地cloudflared进程)。

Gotchas (⚠️ all confirmed in real runs)

注意事项(⚠️ 均在实际运行中已确认)

Token / API

Token / API

  • Cloudflare-Tunnel:Edit
    does NOT grant
    /accounts
    listing.
    Calling
    GET /accounts
    returns an empty list even with a valid token. Solution: derive
    account_id
    from any zone's embedded
    account.id
    field —
    verify.py
    already does this. Do NOT add
    Account:Account Settings:Read
    just to fix it; the zone trick is cleaner.
  • The "run token" from
    GET /accounts/{id}/cfd_tunnel/{tunnel_id}/token
    is what
    cloudflared tunnel run --token
    consumes. Do not confuse with:
    • tunnel secret — only relevant for legacy locally-managed tunnels (we don't use)
    • API Token — used to call api.cloudflare.com
  • Cloudflare-Tunnel:Edit
    权限不允许列出
    /accounts
    。即使Token有效,调用
    GET /accounts
    仍会返回空列表。解决方案:从任意区域的内嵌
    account.id
    字段推导
    account_id
    verify.py
    已实现此逻辑。不要为解决此问题添加
    Account:Account Settings:Read
    权限;区域字段的方法更简洁。
  • GET /accounts/{id}/cfd_tunnel/{tunnel_id}/token
    返回的“运行Token”是
    cloudflared tunnel run --token
    使用的Token。不要与以下混淆:
    • tunnel secret — 仅与旧版本地管理隧道相关(我们不使用)
    • API Token — 用于调用api.cloudflare.com

Tunnel / Ingress

隧道 / 入口规则

  • The CNAME target must be
    <tunnel_id>.cfargotunnel.com
    , NOT the tunnel name.
  • Remotely-managed tunnel (
    config_src: "cloudflare"
    ) routes via the API config endpoint, NOT a local
    config.yml
    . Do not generate one.
  • Creating a tunnel via API requires a
    tunnel_secret
    field (32 random bytes, base64) even for
    config_src=cloudflare
    .
    setup.py
    generates one automatically.
  • CNAME目标必须是
    <tunnel_id>.cfargotunnel.com
    ,而非隧道名称。
  • 远程管理隧道(
    config_src: "cloudflare"
    )通过API配置端点路由,而非本地
    config.yml
    。不要生成该文件。
  • 通过API创建隧道时,即使
    config_src=cloudflare
    ,也需要
    tunnel_secret
    字段(32个随机字节,base64编码)。
    setup.py
    会自动生成该字段。

Universal SSL provisioning lag — the OTHER big trap

通用SSL证书颁发延迟 — 另一个重大陷阱

After DNS propagates, the user may still hit
ERR_SSL_VERSION_OR_CIPHER_MISMATCH
in the browser. This is NOT a bug — Cloudflare hasn't issued the Universal SSL certificate for the new hostname yet.
Diagnosis (run from container — no auth needed):
bash
echo | timeout 10 openssl s_client -connect <hostname>:443 -servername <hostname> 2>&1 | grep -E "(handshake|peer certificate|Cipher is)"
  • no peer certificate available
    +
    handshake failure
    → cert not issued yet ⏳
  • Real cert returned → working ✅
Timing:
  • Established zones with prior certs: usually < 5 min
  • Brand-new domains: 15 min ~ 24 h (DNS validation + CA signing + edge propagation)
What to tell the user:
  1. Open
    dash.cloudflare.com → <domain> → SSL/TLS → Edge Certificates
  2. Look for a row like
    *.<domain>, <domain>
    and check status:
    • Active
      → done, refresh browser
    • Pending Validation
      /
      Initializing
      → wait
  3. Confirm
    SSL/TLS → Overview → Encryption mode
    is Full (not Flexible, not Full Strict). Tunnel always carries HTTPS to the origin, so Full is the right match.
Don't: Tell the user to add an Advanced Certificate ($$$) or to change DNS — neither helps. Just wait.
DNS传播完成后,用户在浏览器中仍可能遇到
ERR_SSL_VERSION_OR_CIPHER_MISMATCH
错误。这不是bug — Cloudflare尚未为新主机名颁发通用SSL证书
诊断(从容器运行 — 无需授权):
bash
echo | timeout 10 openssl s_client -connect <hostname>:443 -servername <hostname> 2>&1 | grep -E "(handshake|peer certificate|Cipher is)"
  • no peer certificate available
    +
    handshake failure
    → 证书尚未颁发 ⏳
  • 返回有效证书 → 服务正常 ✅
时间范围:
  • 已有证书的成熟区域:通常<5分钟
  • 全新域名:15分钟 ~ 24小时(DNS验证 + CA签名 + 边缘节点传播)
告知用户:
  1. 打开
    dash.cloudflare.com → <domain> → SSL/TLS → Edge Certificates
  2. 查找类似
    *.<domain>, <domain>
    的条目并查看状态:
    • Active
      → 已完成,刷新浏览器
    • Pending Validation
      /
      Initializing
      → 等待
  3. 确认
    SSL/TLS → Overview → Encryption mode
    Full(不是Flexible,也不是Full Strict)。隧道始终通过HTTPS连接到源站,因此Full是正确的模式。
不要做: 告知用户添加高级证书(付费)或更改DNS — 这两种方法都无效。只需等待即可。

DNS propagation — the big trap

DNS传播 — 重大陷阱

Newly registered domains take 30 min ~ 2 h (sometimes up to 24 h) to propagate across the global TLD registry, even when the Cloudflare dashboard shows "Active" instantly. Symptoms:
  • dig @1.1.1.1 yourdomain.com NS
    returns NXDOMAIN (Status=3)
  • The Authority section shows the TLD's registry NS (e.g.,
    ns.trs-dns.com
    for
    .fun
    via Tucows), NOT Cloudflare's NS
  • cloudflared
    tunnel is connected and healthy, but
    https://yourdomain.com
    returns DNS resolution failure
This is NOT a bug in the skill — it's TLD registry sync lag. Use the diagnostic snippet below to distinguish it from real issues. Tell the user: "Configuration is complete. Wait 30–60 minutes and try again. Nothing more to do on our side."
新注册的域名需要30分钟 ~ 2小时(有时长达24小时)才能在全球TLD注册商完成传播,即使Cloudflare控制台立即显示“Active”。症状:
  • dig @1.1.1.1 yourdomain.com NS
    返回NXDOMAIN(Status=3)
  • Authority部分显示TLD注册商的NS(例如
    .fun
    域名的
    ns.trs-dns.com
    ,由Tucows提供),而非Cloudflare的NS
  • cloudflared
    隧道已连接且健康,但
    https://yourdomain.com
    返回DNS解析失败
这不是技能的bug — 是TLD注册商同步延迟。 使用以下诊断代码片段区分实际问题。告知用户:“配置已完成。请等待30–60分钟后重试。我们这边无需再进行任何操作。”

Container DNS — false negative

容器DNS — 假阴性

When testing from inside the Starchild container, the container's local resolver may not see new domains for hours. Always cross-check with public DoH:
bash
curl -sS "https://dns.google/resolve?name=hello.example.com&type=A" | python3 -m json.tool
  • Status: 0
    +
    Answer
    array with IPs → working ✅
  • Status: 3
    (NXDOMAIN) +
    Authority: ns.trs-dns.com
    (or similar registry NS) → TLD propagation pending ⏳
  • Status: 0
    but no
    Answer
    → CNAME exists but Cloudflare orange-cloud not yet routing → wait 30s
在Starchild容器内测试时,容器的本地解析器可能在数小时内无法识别新域名。始终通过公共DoH交叉验证:
bash
curl -sS "https://dns.google/resolve?name=hello.example.com&type=A" | python3 -m json.tool
  • Status: 0
    +
    Answer
    数组包含IP地址 → 服务正常 ✅
  • Status: 3
    (NXDOMAIN) +
    Authority: ns.trs-dns.com
    (或类似注册商NS)→ TLD传播待处理 ⏳
  • Status: 0
    但无
    Answer
    → CNAME已存在但Cloudflare橙色云尚未开始路由 → 等待30秒

Keeping it alive — one script, two triggers

保持服务运行 — 一个脚本,两个触发方式

This is the part agents get wrong. "Publish" is easy; keeping a tunnel site up for weeks is the real job. The Starchild container restarts without warning (platform updates, OOM, migration, user reboot), and
cloudflared
also dies on its own mid-life (network blip, edge reset, QUIC failure) while the container keeps running. Either one leaves
https://yourdomain.com
returning 502 / 521 / 530 / 1033 until something restarts the processes. DNS and the Cloudflare-side tunnel config survive (they live on Cloudflare's servers) — only the local processes need relaunching.
The design: ONE idempotent recovery brain (
scripts/keepalive.sh
) called from TWO triggers.
Do not write per-project starter/healer scripts — that's how the two copies drift apart.
keepalive.sh
ships with this skill and is generic: it reads
hostname
,
port
,
app_cmd
,
app_dir
from
.cf_state.json
, so the same file works for any domain. The calling agent writes ZERO project-specific shell.
What
keepalive.sh
does each run:
  1. Probe the public URL (reachability, not just PID — a
    cloudflared
    process can be alive but disconnected).
  2. Healthy → log one line, exit silently.
  3. Down → diagnose: local app port closed → restart app (via
    app_cmd
    ) and tunnel; only the tunnel dead → restart just the tunnel. Then re-verify with a few retries (covers cold-start warm-up).
  4. Report on state transitions only (tracked in
    run/keepalive.state
    ): newly-recovered or newly-failed prints one line; steady-state (healthy, or already-known-down) is silent. So a scheduled task pushes signal, never spam.
这是Agent容易出错的部分。“发布”很简单;让隧道站点持续运行数周才是真正的挑战。Starchild容器可能会无预警重启(平台更新、内存不足、迁移、用户重启),并且
cloudflared
进程也可能在容器运行期间自行崩溃(网络中断、边缘节点重置、QUIC失败)。无论哪种情况,
https://yourdomain.com
都会返回502 / 521 / 530 / 1033错误,直到进程被重启。DNS和Cloudflare端的隧道配置会保留(存储在Cloudflare服务器上) — 只需重启本地进程即可。
设计思路:一个幂等的自愈脚本(
scripts/keepalive.sh
),通过两个触发方式调用。
不要为每个项目编写单独的启动/自愈脚本 — 这会导致多个副本逐渐不一致。
keepalive.sh
随此技能一起发布,具有通用性:它从
.cf_state.json
读取
hostname
port
app_cmd
app_dir
,因此同一个文件适用于任何域名。调用Agent无需编写任何项目特定的shell脚本。
每次运行
keepalive.sh
时会执行以下操作:
  1. 探测公开URL的可达性(不仅仅是PID —
    cloudflared
    进程可能存活但已断开连接)。
  2. 如果健康 → 记录一行日志,静默退出
  3. 如果服务中断 → 诊断:本地应用端口关闭 → 重启应用(通过
    app_cmd
    隧道;仅隧道崩溃 → 仅重启隧道。然后进行几次重试验证(覆盖冷启动预热时间)。
  4. 仅报告状态转换(在
    run/keepalive.state
    中跟踪):服务恢复或新故障发生时打印一行日志;稳定状态(健康或已知故障)则静默。因此定时任务只会发送必要的通知,不会产生垃圾信息。

Step 1 — record how to start the app (at setup time)

步骤1 — 记录应用启动方式(在设置阶段)

Pass
--app-cmd
/
--app-dir
to
setup.py
so keepalive can restart the app, not just the tunnel:
python3 setup.py --hostname app.example.com --port 8765 \
    --app-cmd "python3 server.py" --app-dir projects/myapp
If you omit
--app-cmd
, keepalive guards the tunnel only and cannot revive a crashed app. Always record it unless the app is supervised elsewhere.
setup.py
传递
--app-cmd
/
--app-dir
参数,以便keepalive脚本可以重启应用,而不仅仅是隧道:
python3 setup.py --hostname app.example.com --port 8765 \
    --app-cmd "python3 server.py" --app-dir projects/myapp
如果省略
--app-cmd
,keepalive脚本只会监控隧道,无法恢复崩溃的应用。除非应用由其他进程管理,否则始终记录此参数。

Step 2 — start the site

步骤2 — 启动站点

bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
Idempotent: starts whatever is down, no-op when healthy.
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
幂等操作:启动所有已中断的服务,服务健康时无任何操作。

Step 3 — survive container restarts (boot trigger)

步骤3 — 应对容器重启(开机触发)

Add keepalive to
workspace/setup.sh
(runs on every container boot):
bash
undefined
将keepalive脚本添加到
workspace/setup.sh
(每次容器启动时运行):
bash
undefined

Bring the tunnel site back after a restart. keepalive.sh only READS

重启后恢复隧道站点。keepalive.sh仅读取

.cf_state.json — no Cloudflare API call, no token needed at boot.

.cf_state.json — 开机时无需调用Cloudflare API,也无需Token。

if [ -f /data/workspace/.cf_state.json ]; then bash /data/workspace/skills/cloudflare-tunnel-publish/scripts/keepalive.sh & disown fi

> 🚫 **NEVER put `setup.py` in `setup.sh`.** `setup.py` is config-time: it calls
> the Cloudflare API, may rotate the run_token, and overwrites `.cf_state.json`.
> Running it on every boot is wasteful, can hit rate limits, breaks if the API
> token was removed, and can change a working config. Boot must only *read* state
> — that's exactly what `keepalive.sh` does. The name "setup" tempts you to put
> it in "setup.sh"; resist it.
if [ -f /data/workspace/.cf_state.json ]; then bash /data/workspace/skills/cloudflare-tunnel-publish/scripts/keepalive.sh & disown fi

> 🚫 **永远不要将`setup.py`放入`setup.sh`。** `setup.py`是配置阶段的脚本:它会调用Cloudflare API,可能会轮换运行Token,并覆盖`.cf_state.json`。每次开机运行它会造成资源浪费,可能触发速率限制,如果API Token已移除则会失败,还可能更改正常运行的配置。开机时应仅*读取*状态 — 这正是`keepalive.sh`的作用。“setup”这个名称可能会诱使你将其放入“setup.sh”;请抵制这种诱惑。

Step 4 — survive mid-life process death (watchdog trigger)

步骤4 — 应对进程中途崩溃(监控触发)

Schedule the SAME script as a cheap
command
-mode task. One schedule guards all sites — keepalive.sh iterates every site in
.cf_state.json
in a single pass, so you never need a per-site watchdog:
scheduled_task(action="schedule",
  schedule="every 2 minutes",
  command="cd /data/workspace && bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh",
  title="cloudflare tunnel keepalive (all sites)")
  • Use a relative command with
    cd /data/workspace &&
    . An absolute
    /data/workspace/...
    path can be normalized by the scheduler into a non-existent
    /data/skills/...
    (the
    workspace/
    segment gets dropped), so every run fails silently.
    cd
    + relative path is immune. (Confirmed in a real run.)
  • Keep
    deliver
    at its default so the transition-only alerts actually reach the user. keepalive is already silent on healthy runs, so there's no spam to suppress — and a real outage should ping you.
  • The interval may be normalized (e.g. "every 2 minutes" → 3 min) — fine.
将同一个脚本设置为廉价的
command
模式定时任务。一个定时任务即可监控所有站点 — keepalive.sh会一次性遍历
.cf_state.json
中的所有站点,因此无需为每个站点单独设置监控:
scheduled_task(action="schedule",
  schedule="every 2 minutes",
  command="cd /data/workspace && bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh",
  title="cloudflare tunnel keepalive (all sites)")
  • 使用相对命令,配合
    cd /data/workspace &&
    。绝对路径
    /data/workspace/...
    可能会被调度器规范化为不存在的
    /data/skills/...
    workspace/
    段会被删除),导致每次运行都静默失败。
    cd
    + 相对路径可避免此问题。(已在实际运行中确认。)
  • 保留
    deliver
    默认值,以便状态转换的警报能真正送达用户。keepalive脚本在服务健康时已静默,因此不会产生垃圾信息 — 真正的故障应触发通知。
  • 时间间隔可能会被规范化(例如“every 2 minutes” → 3分钟) — 这是正常的。

Verify durability (do all of this before claiming "stable")

验证持久性(在声称“稳定”前完成所有这些操作)

bash
undefined
bash
undefined

1. start + idempotency

1. 启动 + 幂等性测试

bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # brings up bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # silent no-op tail -5 logs/keepalive.log # ok https://...
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # 启动服务 bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # 静默无操作 tail -5 logs/keepalive.log # 确认日志显示ok https://...

2. boot wired

2. 检查开机启动配置

grep keepalive setup.sh
grep keepalive setup.sh

3. watchdog registered + command stored correctly (relative path!)

3. 确认监控已注册且命令存储正确(相对路径!)

scheduled_task(action="list")

scheduled_task(action="list")

**Don't claim "long-term stable" after only editing `setup.sh`.** That covers
restarts but not mid-life death. Confirm BOTH triggers (boot + schedule) point at
keepalive.sh, and that the first run logged `ok https://...`.

**Port collision is a silent failure.** Other workspace projects may already hold
common ports (8000/8080/8765). If your app's `bind()` fails with `Address already
in use` it exits, but `curl localhost:<port>` still returns 200 — someone else's
app is answering. Use a high, project-unique port and verify the page is *your*
content (`curl https://yourdomain.com | head`), not just a 200.

**Tell the user explicitly:**
> 🔔 你的站点跑在容器里。容器可能因更新/内存/迁移**随时**重启,隧道进程偶尔也会自己掉线(域名变 502/521/530/1033)。我已经把一个自愈脚本写进了 `workspace/setup.sh`(开机自动拉起)并设了每几分钟一次的巡检(掉线自动重拉、恢复/失败才通知你)。两层都指向同一个脚本,你基本不用管。真打不开时让我看一眼 `logs/keepalive.log` 和 `logs/cloudflared.log` 就能定位。
**不要仅编辑`setup.sh`后就声称“长期稳定”。** 这只能覆盖容器重启的情况,无法应对进程中途崩溃。确认两个触发方式(开机 + 定时任务)都指向keepalive.sh,并且首次运行日志显示`ok https://...`。

**端口冲突是静默故障。** 其他工作区项目可能已占用常用端口(8000/8080/8765)。如果你的应用`bind()`失败并返回`Address already in use`,应用会退出,但`curl localhost:<port>`仍可能返回200 — 因为是其他应用在响应。使用较高的、项目唯一的端口,并验证页面内容是*你的*应用(`curl https://yourdomain.com | head`),而不仅仅是返回200状态码。

**明确告知用户:**
> 🔔 你的站点跑在容器里。容器可能因更新/内存/迁移**随时**重启,隧道进程偶尔也会自己掉线(域名变 502/521/530/1033)。我已经把一个自愈脚本写进了 `workspace/setup.sh`(开机自动拉起)并设了每几分钟一次的巡检(掉线自动重拉、恢复/失败才通知你)。两层都指向同一个脚本,你基本不用管。真打不开时让我看一眼 `logs/keepalive.log` 和 `logs/cloudflared.log` 就能定位。

Plan limits

计划限制

  • Free plan is enough. No upsell needed.
  • Free plan only proxies ports 80/443 publicly — irrelevant to us, since the tunnel always exposes 443 to the world;
    localhost:<port>
    can be anything.
  • 免费计划已足够。无需升级付费。
  • 免费计划仅公开代理80/443端口 — 这对我们无关紧要,因为隧道始终向外界暴露443端口;
    localhost:<port>
    可以是任意端口。

State file format

状态文件格式

workspace/.cf_state.json
supports multiple sites. The canonical store is the
sites
array; the flat top-level fields are kept for backward compat and always reflect the last-configured site:
json
{
  "account_id": "...",
  "zone_id": "...",
  "zone_name": "example.com",
  "sites": [
    {
      "hostname": "app.example.com",
      "port": 8080,
      "tunnel_id": "...",
      "tunnel_name": "starchild-app-example-com",
      "run_token": "...",
      "app_cmd": "python3 server.py",
      "app_dir": "/data/workspace/projects/myapp"
    },
    {
      "hostname": "blog.example.com",
      "port": 3000,
      "tunnel_id": "...",
      "run_token": "...",
      "app_cmd": "",
      "app_dir": ""
    }
  ],
  "hostname": "blog.example.com",
  "port": 3000,
  "tunnel_id": "...",
  "run_token": "...",
  "app_cmd": "",
  "app_dir": ""
}
keepalive.sh
reads
sites[]
and guards every site in one pass — one watchdog, all hostnames. This is the fix for the "each site got its own tunnel
  • its own keepalive, and only one was watched" bug: there is now exactly one keepalive process that knows about every configured site.
workspace/.cf_state.json
支持多站点。标准存储是
sites
数组;顶层平级字段是为了向后兼容,始终反映最后配置的站点:
json
{
  "account_id": "...",
  "zone_id": "...",
  "zone_name": "example.com",
  "sites": [
    {
      "hostname": "app.example.com",
      "port": 8080,
      "tunnel_id": "...",
      "tunnel_name": "starchild-app-example-com",
      "run_token": "...",
      "app_cmd": "python3 server.py",
      "app_dir": "/data/workspace/projects/myapp"
    },
    {
      "hostname": "blog.example.com",
      "port": 3000,
      "tunnel_id": "...",
      "run_token": "...",
      "app_cmd": "",
      "app_dir": ""
    }
  ],
  "hostname": "blog.example.com",
  "port": 3000,
  "tunnel_id": "...",
  "run_token": "...",
  "app_cmd": "",
  "app_dir": ""
}
keepalive.sh
读取
sites[]
并一次性监控所有站点 — 一个监控进程,覆盖所有主机名。这解决了“每个站点都有自己的隧道和监控,但只有一个监控被正确配置”的bug:现在只有一个keepalive进程知晓所有已配置的站点。

Multiple sites — the design

多站点设计

One tunnel, many hostnames, one watchdog. This is the only sane topology:
  • setup.py
    defaults to reusing the first healthy tunnel it finds on the account. It merges the new hostname into the tunnel's existing ingress rules (preserving other hostnames) and points the DNS CNAME at that tunnel.
  • .cf_state.json
    holds a
    sites[]
    array. Each
    setup.py
    run appends (or replaces) one entry.
    teardown.py
    removes one entry.
  • keepalive.sh
    iterates
    sites[]
    and guards every site. One process, one watchdog, all hostnames. If a site shares a tunnel with another, the same
    cloudflared
    process serves both — keepalive won't start a second one.
When the agent gets this wrong (the bug that prompted this section): creating a new tunnel per site means N
cloudflared
processes, N PID files, N watchdogs to wire — and in practice only one watchdog ever gets set up. The other tunnels silently die and nobody notices for weeks. Reuse the tunnel, append to
sites[]
, let one keepalive guard them all.
一个隧道,多个主机名,一个监控。 这是唯一合理的拓扑:
  • setup.py
    默认复用账户中找到的第一个健康隧道。它将新主机名合并到隧道的现有入口规则中(保留其他主机名),并将DNS CNAME指向该隧道。
  • .cf_state.json
    包含
    sites[]
    数组。每次运行
    setup.py
    会追加(或替换)一个条目。
    teardown.py
    会删除一个条目。
  • keepalive.sh
    遍历
    sites[]
    并监控所有站点。一个进程,一个监控,覆盖所有主机名。如果多个站点共享一个隧道,同一个
    cloudflared
    进程会为所有站点提供服务 — keepalive不会启动第二个进程。
当Agent处理错误时(促使编写此部分的bug):为每个站点创建新隧道意味着N个
cloudflared
进程、N个PID文件、N个需要配置的监控 — 而实际上通常只有一个监控被正确设置。其他隧道会静默崩溃,数周内无人察觉。复用隧道,追加到
sites[]
,让一个keepalive脚本监控所有站点。