ab / cf_tunnel.py
soxogvv's picture
Upload 5 files
6604842 verified
Raw
History Blame Contribute Delete
6.05 kB
"""
Cloudflare Tunnel manager β€” gives this service a PERMANENT public URL that
works 24/7 without opening any inbound port (cloudflared dials OUT to Cloudflare,
so it works behind NAT / on hosts with no public IP).
Two modes, auto-selected from environment variables
──────────────────────────────────────────────────────────────────────────────
1) NAMED tunnel β†’ PERMANENT url (recommended, this is the "24/7 permanent link")
In the Cloudflare Zero Trust dashboard:
β€’ create a Tunnel, copy its **token**
β€’ add a Public Hostname (e.g. ig.example.com) β†’ Service: http://localhost:<PORT>
Then set on this service:
CLOUDFLARE_TUNNEL_TOKEN = <the tunnel token>
CLOUDFLARE_PUBLIC_URL = https://ig.example.com (your routed hostname)
The hostname NEVER changes across restarts β†’ a real permanent link.
2) QUICK tunnel β†’ free, no account/domain, but the URL CHANGES every start
Set:
CLOUDFLARE_QUICK = 1
cloudflared prints a https://<random>.trycloudflare.com URL which we detect
from its output. Not permanent β€” but because the app re-registers its current
URL to MongoDB on every start, the HF <-> Koyeb sync still keeps working.
The detected/configured public URL is handed to the `on_url` callback so the
caller can publish it (e.g. write it to MongoDB) for the paired app to discover.
If neither env var is set, this module is a no-op (the service still runs, just
without a Cloudflare tunnel).
"""
import os
import re
import time
import shutil
import logging
import threading
import subprocess
log = logging.getLogger("cf-tunnel")
# matches the ephemeral URL cloudflared prints for a quick tunnel
_QUICK_RE = re.compile(r"https://[a-z0-9][a-z0-9-]*\.trycloudflare\.com")
_state = {"started": False, "proc": None, "url": None}
def _cloudflared_bin():
"""Locate the cloudflared binary (PATH first, then common install paths)."""
return (
shutil.which("cloudflared")
or next(
(p for p in ("/usr/local/bin/cloudflared", "/usr/bin/cloudflared")
if os.path.exists(p)),
None,
)
)
def public_url():
"""Best-known public URL for this service (env wins, else detected quick URL)."""
return (
os.getenv("CLOUDFLARE_PUBLIC_URL", "").strip()
or os.getenv("SELF_URL", "").strip()
or _state["url"]
)
def start_tunnel(local_port, on_url=None):
"""Start cloudflared (named or quick) pointing at http://localhost:<local_port>.
Non-blocking and idempotent. A supervisor thread keeps cloudflared alive,
restarting it (with capped backoff) if it ever exits, so the public link
stays up 24/7. The chosen public URL is delivered to `on_url(url)`.
"""
if _state["started"]:
return _state["url"]
token = os.getenv("CLOUDFLARE_TUNNEL_TOKEN", "").strip()
quick = os.getenv("CLOUDFLARE_QUICK", "").strip().lower() in ("1", "true", "yes", "on")
if not token and not quick:
log.info("[cf] tunnel disabled (set CLOUDFLARE_TUNNEL_TOKEN for a permanent "
"link, or CLOUDFLARE_QUICK=1 for a temporary one)")
return None
binary = _cloudflared_bin()
if not binary:
log.error("[cf] cloudflared binary not found β€” install it in the image "
"(see Dockerfile) β€” tunnel disabled")
return None
_state["started"] = True
def build_cmd():
if token:
# named tunnel: the public hostname is configured in the dashboard
return [binary, "tunnel", "--no-autoupdate", "run", "--token", token]
# quick tunnel: ephemeral *.trycloudflare.com pointing at our local port
return [binary, "tunnel", "--no-autoupdate",
"--url", f"http://localhost:{local_port}"]
def announce(url):
if url and url != _state["url"]:
_state["url"] = url
log.info(f"[cf] 🌍 public URL: {url}")
if on_url:
try:
on_url(url)
except Exception as e:
log.warning(f"[cf] on_url callback failed: {e}")
# For a named tunnel the URL is known up-front from env β†’ publish immediately.
if token and public_url():
announce(public_url())
def supervise():
backoff = 5
while True:
try:
# A quick tunnel gets a NEW random URL every (re)start, so forget
# the previous one and let it be re-detected + re-registered below.
# A named tunnel keeps its fixed hostname, so leave it as-is.
if quick:
_state["url"] = None
proc = subprocess.Popen(
build_cmd(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
_state["proc"] = proc
log.info("[cf] cloudflared started")
for line in proc.stdout:
line = line.rstrip()
if line:
log.info(f"[cf] {line}")
backoff = 5 # we're getting healthy output β†’ reset backoff
if quick and not _state["url"]:
m = _QUICK_RE.search(line)
if m:
announce(m.group(0))
proc.wait()
log.warning(f"[cf] cloudflared exited ({proc.returncode}); "
f"restarting in {backoff}s")
except Exception as e:
log.error(f"[cf] cloudflared error: {e}; restarting in {backoff}s")
time.sleep(backoff)
backoff = min(backoff * 2, 60)
threading.Thread(target=supervise, daemon=True, name="cf-tunnel").start()
return _state["url"]