
Cloudflare Tunnel Publish
- 31 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
cloudflare-tunnel-publish is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cloudflare-tunnel-publish
- AI & Agent Building
- AI-coding skill
Cloudflare Tunnel Publish by the numbers
- 31 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #9,164 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill cloudflare-tunnel-publishAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
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.
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.
Workflow
Phase 0 — Set the stage (1 message)
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.
Phase 1 — Get the user a domain on Cloudflare
If they don't have one yet:
- Direct them to https://dash.cloudflare.com/sign-up to register
- Then https://dash.cloudflare.com/?to=/:account/domains to buy a domain (Cloudflare sells
.com / .net / .org / .io / .dev / .appetc. at registry cost), OR add an existing domain and change nameservers - Wait for them to confirm "domain is active in Cloudflare" before proceeding
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.
Phase 2 — Create the 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-tokens → Create Token → Create Custom Token
Required permissions (tell them to add these three):
- Account → Cloudflare Tunnel → Edit
- Zone → DNS → Edit
- Zone → Zone → Read
Account Resources: their account. Zone Resources: Include All zones (or specifically the domain). TTL: leave default.
After they click Continue to summary → Create Token → Cloudflare shows the token once. Tell them: do not paste it in chat.
Phase 3 — Receive the token securely
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.
Phase 4 — Verify token + pick the zone
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.
Phase 5 — Decide what to publish
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>.
Phase 6 — Build the tunnel (automated)
Run python3 skills/cloudflare-tunnel-publish/scripts/setup.py --hostname <full_hostname> --port <port>.
The script does, in order: 1. Create a remotely-managed tunnel (config_src: "cloudflare") named starchild-<hostname> 2. Fetch the tunnel run token (a long base64 string used to start cloudflared) 3. PUT the ingress configuration: <hostname> → http://localhost:<port>, fallback 404 4. Create a CNAME DNS record: <hostname> → <tunnel_id>.cfargotunnel.com, proxied = true 5. Save tunnel_id + run_token + hostname to workspace/.cf_state.json
If a tunnel with the same name exists, reuse it instead of erroring.
Phase 7 — Start 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.
Phase 8 — Verify
⚠️ 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:
curl -sS "https://dns.google/resolve?name=<hostname>&type=A" | python3 -m json.toolThree 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.
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.
- User wants to remove it → run
python3 skills/cloudflare-tunnel-publish/scripts/teardown.py(deletes DNS + tunnel + kills the local cloudflared process).
Gotchas (⚠️ all confirmed in real runs)
Token / API
- `Cloudflare-Tunnel:Edit` does NOT grant `/accounts` listing. Calling
GET /accountsreturns an empty list even with a valid token. Solution: deriveaccount_idfrom any zone's embeddedaccount.idfield —verify.pyalready does this. Do NOT addAccount:Account Settings:Readjust to fix it; the zone trick is cleaner. - The "run token" from
GET /accounts/{id}/cfd_tunnel/{tunnel_id}/tokenis whatcloudflared tunnel run --tokenconsumes. 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
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 localconfig.yml. Do not generate one. - Creating a tunnel via API requires a
tunnel_secretfield (32 random bytes, base64) even forconfig_src=cloudflare.setup.pygenerates one automatically.
Universal SSL provisioning lag — the OTHER big trap
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):
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 browserPending 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 propagation — the big trap
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 NSreturns NXDOMAIN (Status=3)- The Authority section shows the TLD's registry NS (e.g.,
ns.trs-dns.comfor.funvia Tucows), NOT Cloudflare's NS cloudflaredtunnel is connected and healthy, buthttps://yourdomain.comreturns 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."
Container DNS — false negative
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:
curl -sS "https://dns.google/resolve?name=hello.example.com&type=A" | python3 -m json.toolStatus: 0+Answerarray with IPs → working ✅Status: 3(NXDOMAIN) +Authority: ns.trs-dns.com(or similar registry NS) → TLD propagation pending ⏳Status: 0but noAnswer→ CNAME exists but Cloudflare orange-cloud not yet routing → wait 30s
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.
Step 1 — record how to start the app (at setup time)
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/myappIf you omit --app-cmd, keepalive guards the tunnel only and cannot revive a crashed app. Always record it unless the app is supervised elsewhere.
Step 2 — start the site
bash skills/cloudflare-tunnel-publish/scripts/keepalive.shIdempotent: starts whatever is down, no-op when healthy.
Step 3 — survive container restarts (boot trigger)
Add keepalive to workspace/setup.sh (runs on every container boot):
# Bring the tunnel site back after a restart. keepalive.sh only READS
# .cf_state.json — no Cloudflare API call, no token needed at boot.
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 callsthe 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 putit in "setup.sh"; resist it.
Step 4 — survive mid-life process death (watchdog trigger)
Schedule the SAME script as a cheap command-mode task:
scheduled_task(action="schedule",
schedule="every 2 minutes",
command="cd /data/workspace && bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh",
title="<hostname> keepalive")- 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
deliverat 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.
Verify durability (do all of this before claiming "stable")
# 1. start + idempotency
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://...
# 2. boot wired
grep keepalive setup.sh
# 3. watchdog registered + command stored correctly (relative path!)
# 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就能定位。
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.
State file format
workspace/.cf_state.json:
{
"account_id": "...",
"zone_id": "...",
"zone_name": "example.com",
"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"
}Use this for teardown, status checks, and re-runs without re-asking the user.
"""Cloudflare API helpers — shared by verify.py / setup.py / teardown.py.
Uses the user's CLOUDFLARE_API_TOKEN from workspace/.env. All calls go direct
to api.cloudflare.com (Cloudflare's own endpoint, not via sc-proxy — this is
the user's own credential, not a platform-billed API).
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
import urllib.request
import urllib.error
WORKSPACE = Path("/data/workspace")
STATE_PATH = WORKSPACE / ".cf_state.json"
ENV_PATH = WORKSPACE / ".env"
API_BASE = "https://api.cloudflare.com/client/v4"
def _load_env() -> None:
"""Load workspace/.env into os.environ (very small parser)."""
if not ENV_PATH.exists():
return
for line in ENV_PATH.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip()
v = v.strip().strip('"').strip("'")
os.environ.setdefault(k, v)
def get_token() -> str:
_load_env()
tok = os.environ.get("CLOUDFLARE_API_TOKEN", "").strip()
if not tok:
print("ERROR: CLOUDFLARE_API_TOKEN not set in workspace/.env", file=sys.stderr)
sys.exit(2)
return tok
def cf_request(method: str, path: str, body: dict | None = None) -> dict[str, Any]:
"""Call Cloudflare API. Returns parsed JSON. Raises on HTTP error."""
token = get_token()
url = f"{API_BASE}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body_text = e.read().decode(errors="replace")
print(f"HTTP {e.code} on {method} {path}\n{body_text}", file=sys.stderr)
raise
def load_state() -> dict[str, Any]:
if STATE_PATH.exists():
return json.loads(STATE_PATH.read_text())
return {}
def save_state(state: dict[str, Any]) -> None:
STATE_PATH.write_text(json.dumps(state, indent=2))
def update_state(**kwargs) -> dict[str, Any]:
s = load_state()
s.update(kwargs)
save_state(s)
return s
"""End-to-end status check after setup. Tells the user EXACTLY which stage
is still pending: DNS propagation, SSL cert provisioning, or service-side.
Run anytime after setup.py to see current state without bothering the user.
"""
from __future__ import annotations
import json
import socket
import ssl
import subprocess
import sys
import urllib.request
from cf_api import load_state
def doh_lookup(name: str) -> tuple[int, list[str], str]:
"""Returns (status, ip_list, authority_summary)."""
req = urllib.request.Request(
f"https://dns.google/resolve?name={name}&type=A",
headers={"accept": "application/dns-json"},
)
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
ips = [a["data"] for a in d.get("Answer", []) if a.get("type") == 1]
auth = ", ".join(a.get("data", "")[:60] for a in d.get("Authority", []))
return d.get("Status", -1), ips, auth
def tls_check(host: str) -> tuple[bool, str]:
"""True if TLS handshake completes and a cert is presented."""
try:
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
cn = dict(x[0] for x in cert.get("subject", []))
return True, f"cert CN={cn.get('commonName', '?')}, issuer={cert.get('issuer', [[('?',)]])[0][0][1]}"
except ssl.SSLError as e:
return False, f"SSL error: {e}"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
def http_check(host: str) -> tuple[int, int]:
"""Returns (status_code, body_size)."""
try:
with urllib.request.urlopen(f"https://{host}/", timeout=15) as r:
body = r.read()
return r.status, len(body)
except urllib.error.HTTPError as e:
return e.code, 0
except Exception:
return 0, 0
def main() -> int:
state = load_state()
host = state.get("hostname")
if not host:
print("❌ No hostname in state. Run setup.py first.")
return 2
print(f"🔍 Diagnosing {host}\n")
# Stage 1: DNS
status, ips, auth = doh_lookup(host)
if status == 0 and ips:
print(f"✅ DNS: {host} → {', '.join(ips)}")
elif status == 3:
if "trs-dns" in auth or "tucows" in auth or "verisign" in auth:
print(f"⏳ DNS: TLD registry hasn't propagated the new domain yet.")
print(f" Authority = {auth}")
print(f" → Wait 30 min – 24 h for new domains. Nothing to fix.")
else:
print(f"⏳ DNS: NXDOMAIN. Authority = {auth or '(none)'}")
return 0
else:
print(f"⚠ DNS: Status={status}, IPs={ips}, Authority={auth}")
return 0
# Stage 2: TLS
ok, info = tls_check(host)
if ok:
print(f"✅ TLS: {info}")
else:
print(f"⏳ TLS: {info}")
print(f" → Cloudflare Universal SSL not issued yet for this hostname.")
print(f" → Check: dash.cloudflare.com → {state.get('zone_name','<domain>')} → SSL/TLS → Edge Certificates")
print(f" → Typical wait for new domains: 15 min – 24 h. Nothing else to fix.")
return 0
# Stage 3: HTTP
code, size = http_check(host)
if code == 200:
print(f"✅ HTTP: 200 OK ({size} bytes)")
print(f"\n🎉 https://{host}/ is LIVE.")
elif code in (301, 302):
print(f"✅ HTTP: {code} redirect")
elif code in (502, 521, 522, 523, 530):
print(f"❌ HTTP: {code} — Tunnel up but local service unreachable.")
print(f" → Make sure your service is running on port {state.get('port')}")
else:
print(f"⚠ HTTP: {code}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# keepalive.sh — the SINGLE recovery brain for a Cloudflare-tunnel site.
#
# One idempotent script used in BOTH places, so start-up and self-heal can
# never drift apart:
# • boot : called once from workspace/setup.sh (survives container restart)
# • watchdog : called on a schedule_task every few minutes (survives mid-life
# process death — cloudflared exiting on a network blip etc.)
#
# It is generic. It reads everything it needs from .cf_state.json:
# hostname, port, tunnel_name, and (optional) app_cmd + app_dir.
# So the same file works for ANY domain published with this skill — the calling
# agent writes ZERO project-specific shell.
#
# It NEVER calls the Cloudflare API and NEVER needs CLOUDFLARE_API_TOKEN.
# Configuration (tunnel/DNS creation, run_token) is done once by setup.py and
# persisted in .cf_state.json; keepalive only reads that and (re)launches local
# processes. This is why it is safe to run on every boot and every few minutes.
#
# Reporting (so a scheduled command-task pushes signal, not spam):
# • healthy run -> SILENT (log line only)
# • newly down, auto-recovered -> prints ONE line (incident + resolution)
# • newly down, still failing -> prints ONE line (incident needs a human)
# • already-known down, still down -> SILENT (no repeat spam)
# State is tracked in run/keepalive.state so only transitions speak.
set -uo pipefail
WS="/data/workspace"
STATE="$WS/.cf_state.json"
LOGDIR="$WS/logs"; PIDDIR="$WS/run"
mkdir -p "$LOGDIR" "$PIDDIR"
LASTSTATE="$PIDDIR/keepalive.state"
ts() { date -u +'%Y-%m-%dT%H:%M:%SZ'; }
log() { echo "[$(ts)] $*" >> "$LOGDIR/keepalive.log"; }
[ -f "$STATE" ] || {
log "no .cf_state.json — run setup.py once to configure. nothing to do."
exit 0
}
# --- read config from state (pure local read, no API) ---
read_state() { python3 -c "import json,sys;print(json.load(open('$STATE')).get('$1',''))" 2>/dev/null; }
HOST="$(read_state hostname)"
PORT="$(read_state port)"
APP_CMD="$(read_state app_cmd)"
APP_DIR="$(read_state app_dir)"
[ -n "$HOST" ] || { log "no hostname in state — cannot guard."; exit 0; }
[ -n "$APP_DIR" ] || APP_DIR="$WS"
APP_PID="$PIDDIR/app.pid"
TUN_PID="$PIDDIR/cloudflared.pid"
# --- probes ---
public_ok() { curl -fsS --max-time 12 "https://$HOST/" >/dev/null 2>&1; }
local_ok() {
[ -n "$PORT" ] || { echo skip; return; } # no app to manage -> tunnel-only mode
python3 - "$PORT" <<'PY'
import socket, sys
s = socket.socket(); s.settimeout(1.0)
try:
s.connect(("127.0.0.1", int(sys.argv[1]))); print("open")
except Exception:
print("closed")
finally:
s.close()
PY
}
# --- recovery actions (idempotent) ---
start_app() {
[ -n "$APP_CMD" ] || { log "app down but no app_cmd in state — skipping app start"; return 1; }
cd "$APP_DIR" 2>/dev/null || { log "ERROR app_dir not found: $APP_DIR"; return 1; }
setsid bash -c "exec $APP_CMD" >> "$LOGDIR/app.log" 2>&1 < /dev/null &
echo $! > "$APP_PID"
log "started app: ($APP_CMD) in $APP_DIR pid=$(cat "$APP_PID")"
}
start_tunnel() {
[ -f "$TUN_PID" ] && kill "$(cat "$TUN_PID" 2>/dev/null)" 2>/dev/null || true
pkill -f "cloudflared tunnel .* run --token" 2>/dev/null || true
rm -f "$TUN_PID"; sleep 2
setsid bash -c "exec bash $WS/skills/cloudflare-tunnel-publish/scripts/run_tunnel.sh" \
>> "$LOGDIR/cloudflared.log" 2>&1 < /dev/null &
echo $! > "$TUN_PID"
log "(re)started cloudflared pid=$(cat "$TUN_PID")"
}
heal() {
local lo; lo="$(local_ok)"
if [ "$lo" = "closed" ]; then
log "WARN local app :$PORT down -> restart app + tunnel"
start_app
start_tunnel
else
log "WARN tunnel down (local :$PORT ${lo}) -> restart tunnel"
start_tunnel
fi
}
prev="UP"; [ -f "$LASTSTATE" ] && prev="$(cat "$LASTSTATE" 2>/dev/null || echo UP)"
# --- fast path: healthy ---
if public_ok; then
log "ok https://$HOST"
if [ "$prev" = "DOWN" ]; then
echo "✅ $HOST recovered at $(ts)"
fi
echo "UP" > "$LASTSTATE"
exit 0
fi
# --- down: heal, then verify with a few retries (covers cold-start tunnel warm-up) ---
heal
ok=0
for _ in 1 2 3; do
sleep 5
if public_ok; then ok=1; break; fi
done
if [ "$ok" = "1" ]; then
log "recovered https://$HOST"
echo "🔧 $HOST was down — auto-recovered at $(ts)"
echo "UP" > "$LASTSTATE"
exit 0
else
log "ERROR still down https://$HOST after heal"
if [ "$prev" != "DOWN" ]; then
echo "🚨 $HOST is DOWN and auto-recovery failed at $(ts) — needs a manual look (check logs/cloudflared.log, logs/app.log)"
fi
echo "DOWN" > "$LASTSTATE"
exit 1
fi
#!/usr/bin/env bash
# Download cloudflared if missing, then start the tunnel.
# Run with bash(background=true) — this script blocks while cloudflared runs.
set -euo pipefail
WORKSPACE="/data/workspace"
BIN_DIR="$WORKSPACE/bin"
BIN="$BIN_DIR/cloudflared"
STATE="$WORKSPACE/.cf_state.json"
mkdir -p "$BIN_DIR"
if [[ ! -x "$BIN" ]]; then
echo "→ Downloading cloudflared..."
arch=$(uname -m)
case "$arch" in
x86_64) suffix="amd64" ;;
aarch64|arm64) suffix="arm64" ;;
*) echo "Unsupported arch: $arch"; exit 2 ;;
esac
url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${suffix}"
curl -fsSL -o "$BIN" "$url"
chmod +x "$BIN"
echo "✅ cloudflared installed at $BIN"
fi
if [[ ! -f "$STATE" ]]; then
echo "❌ Missing $STATE — run setup.py first."
exit 2
fi
TOKEN=$(python3 -c "import json; print(json.load(open('$STATE'))['run_token'])")
if [[ -z "$TOKEN" ]]; then
echo "❌ run_token missing from state."
exit 2
fi
echo "→ Starting cloudflared tunnel..."
exec "$BIN" tunnel --no-autoupdate run --token "$TOKEN"
"""Create (or reuse) a remotely-managed Cloudflare Tunnel, configure ingress,
create the DNS CNAME, and save everything to .cf_state.json.
Usage:
python3 setup.py --hostname app.example.com --port 8080
python3 setup.py --hostname app.example.com --port 8080 --service-host 127.0.0.1
# Recommended: record how to (re)start the local app so keepalive.sh can bring
# the WHOLE site back by itself after a container restart or an app crash:
python3 setup.py --hostname app.example.com --port 8080 \
--app-cmd "python3 server.py" --app-dir projects/myapp
setup.py is CONFIG-TIME, not start-time. It calls the Cloudflare API to
create/reuse the tunnel + DNS and writes .cf_state.json. Run it ONCE per domain
(or again only when reconfiguring). NEVER put setup.py in workspace/setup.sh —
on every restart it would re-hit the API and may rotate the run_token. Boot and
self-heal are handled by keepalive.sh, which only READS .cf_state.json.
"""
from __future__ import annotations
import argparse
import base64
import os
import secrets
import sys
from cf_api import cf_request, load_state, update_state
def find_existing_tunnel(account_id: str, name: str) -> dict | None:
"""List tunnels and return one with matching name (not deleted)."""
r = cf_request("GET", f"/accounts/{account_id}/cfd_tunnel?is_deleted=false&name={name}")
for t in r.get("result", []):
if t.get("name") == name and not t.get("deleted_at"):
return t
return None
def create_tunnel(account_id: str, name: str) -> dict:
"""Create a remotely-managed tunnel (config_src=cloudflare).
Note: API also requires `tunnel_secret` — a 32-byte base64 string. Even for
config_src=cloudflare, supplying a secret keeps the API happy and lets
locally-managed mode work as fallback.
"""
secret = base64.b64encode(secrets.token_bytes(32)).decode()
body = {"name": name, "config_src": "cloudflare", "tunnel_secret": secret}
r = cf_request("POST", f"/accounts/{account_id}/cfd_tunnel", body=body)
return r["result"]
def fetch_run_token(account_id: str, tunnel_id: str) -> str:
r = cf_request("GET", f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token")
# `result` is the token string (already a JSON-encoded string)
return r["result"]
def put_ingress_config(
account_id: str, tunnel_id: str, hostname: str, service_url: str
) -> None:
body = {
"config": {
"ingress": [
{"hostname": hostname, "service": service_url},
{"service": "http_status:404"},
]
}
}
cf_request(
"PUT",
f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations",
body=body,
)
def upsert_dns_cname(zone_id: str, hostname: str, target: str) -> dict:
"""Create or update a proxied CNAME record."""
# Look for an existing record at this name
existing = cf_request("GET", f"/zones/{zone_id}/dns_records?name={hostname}")
body = {
"type": "CNAME",
"name": hostname,
"content": target,
"proxied": True,
"ttl": 1, # 1 = automatic
"comment": "Managed by cloudflare-tunnel-publish skill",
}
if existing.get("result"):
rec = existing["result"][0]
r = cf_request(
"PUT", f"/zones/{zone_id}/dns_records/{rec['id']}", body=body
)
else:
r = cf_request("POST", f"/zones/{zone_id}/dns_records", body=body)
return r["result"]
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--hostname", required=True, help="Public hostname, e.g. app.example.com")
p.add_argument("--port", required=True, type=int, help="Local port to expose")
p.add_argument("--service-host", default="localhost", help="Local host (default: localhost)")
p.add_argument("--app-cmd", default="", help="Command to (re)start the local app, e.g. 'python3 server.py'. "
"Recorded in state so keepalive.sh can restart the app after a "
"restart/crash. Omit if the app is managed elsewhere.")
p.add_argument("--app-dir", default="", help="Working dir for --app-cmd, relative to /data/workspace "
"(default: workspace root).")
args = p.parse_args()
state = load_state()
account_id = state.get("account_id")
zone_id = state.get("zone_id")
zone_name = state.get("zone_name")
if not account_id or not zone_id:
print("❌ Missing account_id / zone_id in .cf_state.json — run verify.py first.")
return 2
if not args.hostname.endswith(zone_name):
print(f"❌ Hostname {args.hostname} is not in zone {zone_name}")
return 2
tunnel_name = "starchild-" + args.hostname.replace(".", "-")
service_url = f"http://{args.service_host}:{args.port}"
# 1. Reuse or create tunnel
existing = find_existing_tunnel(account_id, tunnel_name)
if existing:
tunnel = existing
print(f"↻ Reusing existing tunnel: {tunnel_name} ({tunnel['id']})")
else:
tunnel = create_tunnel(account_id, tunnel_name)
print(f"✅ Created tunnel: {tunnel_name} ({tunnel['id']})")
# 2. Run token (refetch every time in case of rotation)
run_token = fetch_run_token(account_id, tunnel["id"])
print(f"✅ Fetched run token (length {len(run_token)})")
# 3. Ingress config
put_ingress_config(account_id, tunnel["id"], args.hostname, service_url)
print(f"✅ Set ingress: {args.hostname} → {service_url}")
# 4. DNS CNAME
cname_target = f"{tunnel['id']}.cfargotunnel.com"
upsert_dns_cname(zone_id, args.hostname, cname_target)
print(f"✅ DNS: {args.hostname} CNAME {cname_target} (proxied)")
# 5. Persist
app_dir = args.app_dir.strip()
if app_dir and not app_dir.startswith("/"):
app_dir = f"/data/workspace/{app_dir}"
update_state(
hostname=args.hostname,
port=args.port,
service_url=service_url,
tunnel_id=tunnel["id"],
tunnel_name=tunnel_name,
run_token=run_token,
app_cmd=args.app_cmd.strip(),
app_dir=app_dir,
)
if args.app_cmd.strip():
print(f"✅ Recorded app_cmd → keepalive.sh can auto-restart the app")
else:
print("ℹ️ No --app-cmd given: keepalive.sh will guard the tunnel only "
"(it can't restart your app if it dies). Re-run with --app-cmd to enable full self-heal.")
print(f"\n→ State saved. Start (and self-heal) the whole site with ONE script:")
print(f" bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh")
print(f"→ After ~15s, check: curl -I https://{args.hostname}")
print(f"→ For durability, add keepalive.sh to workspace/setup.sh AND schedule it. See SKILL.md.")
return 0
if __name__ == "__main__":
sys.exit(main())
"""Remove DNS record + delete tunnel + clear state. Use when user wants to
disconnect a custom domain.
Does NOT kill the cloudflared background process — caller should do that
via bash_process(action='kill', session_id=state['cloudflared_session_id']).
"""
from __future__ import annotations
import sys
from cf_api import cf_request, load_state, save_state, STATE_PATH
def delete_dns(zone_id: str, hostname: str) -> int:
r = cf_request("GET", f"/zones/{zone_id}/dns_records?name={hostname}")
n = 0
for rec in r.get("result", []):
cf_request("DELETE", f"/zones/{zone_id}/dns_records/{rec['id']}")
n += 1
return n
def delete_tunnel(account_id: str, tunnel_id: str) -> bool:
# Tunnel must have no active connections; cleanup first
cf_request("DELETE", f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections")
cf_request("DELETE", f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}")
return True
def main() -> int:
state = load_state()
if not state:
print("Nothing to tear down — no .cf_state.json")
return 0
zone_id = state.get("zone_id")
hostname = state.get("hostname")
account_id = state.get("account_id")
tunnel_id = state.get("tunnel_id")
if zone_id and hostname:
n = delete_dns(zone_id, hostname)
print(f"✅ Deleted {n} DNS record(s) for {hostname}")
if account_id and tunnel_id:
try:
delete_tunnel(account_id, tunnel_id)
print(f"✅ Deleted tunnel {tunnel_id}")
except Exception as e:
print(f"⚠ Tunnel delete failed (you may need to stop cloudflared first): {e}")
# Clear publishing state but keep account/zone for next run
keep = {
k: state[k]
for k in ("account_id", "zone_id", "zone_name")
if k in state
}
save_state(keep)
print(f"→ State reset. Account/zone kept for next setup.")
return 0
if __name__ == "__main__":
sys.exit(main())
"""Verify CLOUDFLARE_API_TOKEN, fetch account_id, list zones.
Output is human-readable so the agent can show it directly to the user.
Side effect: writes account_id (and the first zone if only one) to .cf_state.json.
"""
from __future__ import annotations
import sys
from cf_api import cf_request, update_state, load_state
def main() -> int:
# 1. Verify token is valid
try:
v = cf_request("GET", "/user/tokens/verify")
except Exception as e:
print(f"❌ Token verification failed: {e}")
return 1
if not v.get("success"):
print(f"❌ Token invalid: {v}")
return 1
print("✅ Token valid")
# 2. Fetch zones first — each zone embeds account.id, so we can derive
# account_id even if /accounts isn't visible to this token (the
# Cloudflare-Tunnel:Edit perm doesn't include account-listing).
zones = cf_request("GET", "/zones?per_page=50")
zone_list = zones.get("result", [])
if not zone_list:
print("⚠ No domains found. Add a domain to Cloudflare first.")
return 0
# Derive account_id from zones
account_ids = {z["account"]["id"] for z in zone_list}
if len(account_ids) > 1:
print("⚠ Zones span multiple accounts — using the first zone's account.")
acct = zone_list[0]["account"]
account_id = acct["id"]
print(f"✅ Account: {acct.get('name', '(name hidden)')} ({account_id})")
print(f"\n✅ Domains on this account ({len(zone_list)}):")
for i, z in enumerate(zone_list, 1):
status_flag = "✓" if z["status"] == "active" else f"⚠ {z['status']}"
print(f" {i}. {z['name']} [{status_flag}] zone_id={z['id']}")
# If exactly one active zone, auto-select it
active = [z for z in zone_list if z["status"] == "active"]
state_update = {"account_id": account_id}
if len(active) == 1:
z = active[0]
state_update.update(zone_id=z["id"], zone_name=z["name"])
print(f"\n→ Auto-selected the only active zone: {z['name']}")
update_state(**state_update)
print(f"\nState saved to /data/workspace/.cf_state.json")
return 0
if __name__ == "__main__":
sys.exit(main())