""" 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: Then set on this service: CLOUDFLARE_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://.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:. 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"]