File size: 6,052 Bytes
6604842 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """
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"]
|