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"]