| """ |
| 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") |
|
|
| |
| _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: |
| |
| return [binary, "tunnel", "--no-autoupdate", "run", "--token", token] |
| |
| 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}") |
|
|
| |
| if token and public_url(): |
| announce(public_url()) |
|
|
| def supervise(): |
| backoff = 5 |
| while True: |
| try: |
| |
| |
| |
| 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 |
| 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"] |
|
|