From 7066318a99e5af59a8f1045518d268c3cc15b507 Mon Sep 17 00:00:00 2001 From: Nathan Drezner Date: Wed, 30 Sep 2026 16:48:44 -0400 Subject: [PATCH 1/5] Add tunnel option to app.run for a public Cloudflare quick tunnel URL --- CHANGELOG.md | 1 + dash/_configs.py | 1 + dash/_tunnel.py | 159 +++++++++++++++++++++++++++++++ dash/dash.py | 31 ++++++ tests/integration/test_tunnel.py | 142 +++++++++++++++++++++++++++ tests/unit/test_tunnel.py | 50 ++++++++++ 6 files changed, 384 insertions(+) create mode 100644 dash/_tunnel.py create mode 100644 tests/integration/test_tunnel.py create mode 100644 tests/unit/test_tunnel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb24b979c..1e9ce544f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- Add `tunnel=True` to `app.run` (env `DASH_TUNNEL`) to share a locally running app on a public `trycloudflare.com` URL through a Cloudflare quick tunnel. No account needed; `cloudflared` is used from the PATH or downloaded once. For development only. - [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974). - [#3947](https://github.com/plotly/dash/pull/3947) Make `plotly-cloud` a default install dependency of Dash instead of an optional extra, so the `plotly` CLI and Dash's cloud integration work out of the box. The `dash[cloud]` extra is kept for backward compatibility. - [#3930](https://github.com/plotly/dash/pull/3930) Add shared storage: a backend-agnostic cross-process state manager (key/value with optional TTL, plus ordered replayable pub/sub) on every app via `dash.ctx.shared_storage`, started lazily and disabled with `shared_storage=None`. Ships `LocalSharedStorage` (default, in-memory with optional disk persistence), `DiskcacheSharedStorage`, and `RedisSharedStorage` for horizontally-scaled deployments; see `.ai/ARCHITECTURE.md`. diff --git a/dash/_configs.py b/dash/_configs.py index 0e1ab75505..e709f7daa4 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -35,6 +35,7 @@ def load_dash_env_vars(): "DASH_COMPRESS", "DASH_MCP_ENABLED", "DASH_MCP_PATH", + "DASH_TUNNEL", "HOST", "PORT", ) diff --git a/dash/_tunnel.py b/dash/_tunnel.py new file mode 100644 index 0000000000..1f03e2d472 --- /dev/null +++ b/dash/_tunnel.py @@ -0,0 +1,159 @@ +"""Expose a locally running Dash app through a Cloudflare quick tunnel. + +Quick tunnels need no Cloudflare account. ``cloudflared`` is used from the +PATH when present, otherwise the official release is downloaded once and +cached. +""" + +import atexit +import os +import platform +import re +import shutil +import signal +import subprocess +import sys +import tarfile +import tempfile +import threading +import urllib.request + +RELEASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/" +# api.trycloudflare.com shows up in cloudflared's own error messages. +TUNNEL_URL_RE = re.compile(r"https://(?!api\.)[-a-z0-9]+\.trycloudflare\.com") + + +def _release_asset(): + machine = platform.machine().lower() + arch = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"} + if machine in arch: + name = {"darwin": "darwin", "linux": "linux", "win32": "windows"}.get( + sys.platform + ) + suffix = {"darwin": ".tgz", "windows": ".exe"}.get(name, "") + if name: + return f"cloudflared-{name}-{arch[machine]}{suffix}" + raise RuntimeError( + f"No cloudflared build for {sys.platform}/{machine}. Install cloudflared " + "yourself and make sure it is on your PATH: " + "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" + ) + + +def _download_cloudflared(logger): + asset = _release_asset() + exe_name = "cloudflared.exe" if sys.platform == "win32" else "cloudflared" + target_dir = os.path.expanduser(os.path.join("~", ".cache", "dash", "cloudflared")) + target = os.path.join(target_dir, exe_name) + if os.path.isfile(target): + return target + + os.makedirs(target_dir, exist_ok=True) + logger.info("Downloading cloudflared from %s%s", RELEASE_URL, asset) + with tempfile.TemporaryDirectory(dir=target_dir) as tmp: + download = os.path.join(tmp, asset) + with urllib.request.urlopen(RELEASE_URL + asset, timeout=60) as resp, open( + download, "wb" + ) as out: + shutil.copyfileobj(resp, out) + if asset.endswith(".tgz"): + with tarfile.open(download) as tgz: + tgz.extract("cloudflared", tmp, filter="data") + download = os.path.join(tmp, exe_name) + os.chmod(download, 0o755) + # Rename last so a failed download never leaves a broken binary behind. + os.replace(download, target) + return target + + +def find_cloudflared(logger): + return shutil.which("cloudflared") or _download_cloudflared(logger) + + +def local_url(protocol, host, port): + if host in ("0.0.0.0", ""): + host = "127.0.0.1" + elif host == "::": + host = "::1" + if ":" in host: + host = f"[{host}]" + return f"{protocol}://{host}:{port}" + + +class Tunnel: + def __init__(self, process): + self.process = process + self.public_url = None + + def stop(self): + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + + +def _stop_on_sigterm(tunnel): + # atexit does not run on SIGTERM, which would leave cloudflared + # exposing the port after Dash is gone. + if threading.current_thread() is not threading.main_thread(): + return + original = signal.getsignal(signal.SIGTERM) + + def handler(sig, frame): + tunnel.stop() + if callable(original): + original(sig, frame) + elif original == signal.SIG_DFL: + signal.signal(sig, signal.SIG_DFL) + signal.raise_signal(sig) + + signal.signal(signal.SIGTERM, handler) + + +def start_tunnel(target_url, logger, path="/"): + cmd = [ + find_cloudflared(logger), + "tunnel", + "--no-autoupdate", + "--url", + target_url, + ] + if target_url.startswith("https"): + # The local server usually has a self signed or adhoc certificate. + cmd.append("--no-tls-verify") + + process = subprocess.Popen( # pylint: disable=consider-using-with + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + errors="replace", + ) + tunnel = Tunnel(process) + atexit.register(tunnel.stop) + _stop_on_sigterm(tunnel) + + def watch(): + output = [] + # cloudflared keeps logging for its whole life, so the pipe must be + # drained even after the URL is found or it will block. + for line in process.stderr: # type: ignore + if tunnel.public_url: + continue + output.append(line) + match = TUNNEL_URL_RE.search(line) + if match: + tunnel.public_url = match.group(0) + logger.info( + "Dash is publicly available at %s%s\n", tunnel.public_url, path + ) + if not tunnel.public_url: + logger.error( + "The cloudflared tunnel exited before it was ready:\n%s", + "".join(output[-20:]), + ) + + threading.Thread(target=watch, daemon=True).start() + return tunnel diff --git a/dash/dash.py b/dash/dash.py index 19892085a0..34449916c3 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -93,6 +93,7 @@ _import_layouts_from_pages, ) from ._jupyter import jupyter_dash, JupyterDisplayMode +from ._tunnel import local_url, start_tunnel from .types import CallbackExecutionBody, RendererHooks RouteCallable = Callable[..., Any] @@ -649,6 +650,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches # MCP (Model Context Protocol) configuration self._enable_mcp = get_combined_config("mcp_enabled", enable_mcp, False) + self._tunnel = None _mcp_path = get_combined_config("mcp_path", mcp_path, "_mcp") self._mcp_path = ( _mcp_path.lstrip("/") if isinstance(_mcp_path, str) else _mcp_path @@ -2520,6 +2522,7 @@ def run( dev_tools_disable_version_check: Optional[bool] = None, dev_tools_prune_errors: Optional[bool] = None, dev_tools_validate_callbacks: Optional[bool] = None, + tunnel: Optional[bool] = None, **flask_run_options, ): """Start the flask server in local mode, you should not run this on a @@ -2629,6 +2632,13 @@ def run( :param jupyter_server_url: Custom server url to display the app in jupyter notebook. + :param tunnel: Share the app on a public ``trycloudflare.com`` URL + through a Cloudflare quick tunnel. No Cloudflare account needed. + Uses ``cloudflared`` from your PATH, or downloads it once if missing. + Anyone with the URL can reach the app. For development only. + env: ``DASH_TUNNEL`` + :type tunnel: bool + :param flask_run_options: Given to `Flask.run` :return: @@ -2667,6 +2677,12 @@ def run( assert host port = port or os.getenv("PORT", "8050") proxy = proxy or os.getenv("DASH_PROXY") + tunnel = get_combined_config("tunnel", tunnel, False) in ( + True, + "1", + "yes", + "on", + ) # Verify port value try: @@ -2720,6 +2736,21 @@ def verify_url_part(served_part, url_part, part_name): self._mcp_path, ) + if tunnel: + if debug: + self.logger.warning( + "The tunnel is public and debug is on: anyone with the " + "URL can see dev tools and error tracebacks.\n" + ) + if self._tunnel: + self._tunnel.stop() + try: + self._tunnel = start_tunnel( + local_url(protocol, host, port), self.logger, path + ) + except Exception as e: # pylint: disable=broad-exception-caught + self.logger.error("Could not start the tunnel: %s\n", e) + if self.config.extra_hot_reload_paths: extra_files = flask_run_options["extra_files"] = [] for path in self.config.extra_hot_reload_paths: diff --git a/tests/integration/test_tunnel.py b/tests/integration/test_tunnel.py new file mode 100644 index 0000000000..56226f56bb --- /dev/null +++ b/tests/integration/test_tunnel.py @@ -0,0 +1,142 @@ +import json +import os +import signal +import socket +import subprocess +import sys +import textwrap +import time + +import pytest +import requests + +from dash import Dash, html + +FAKE_URL = "https://quick-test-tunnel.trycloudflare.com" + + +@pytest.fixture +def fake_cloudflared(tmp_path, monkeypatch): + args_file = tmp_path / "args.json" + script = tmp_path / "cloudflared" + script.write_text( + textwrap.dedent( + f"""\ + #!{sys.executable} + import json, os, sys, time + with open({str(tmp_path / "pid")!r}, "w") as f: + f.write(str(os.getpid())) + with open({str(args_file)!r}, "w") as f: + json.dump(sys.argv[1:], f) + print("INF Requesting new quick Tunnel on trycloudflare.com...", file=sys.stderr) + print("INF | {FAKE_URL} |", file=sys.stderr, flush=True) + time.sleep(60) + """ + ) + ) + script.chmod(0o755) + monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}{os.environ['PATH']}") + return args_file + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def wait_for(condition, timeout=10): + end = time.time() + timeout + while time.time() < end: + if condition(): + return + time.sleep(0.1) + raise AssertionError("timed out") + + +@pytest.mark.skipif(sys.platform == "win32", reason="fake cloudflared is a script") +def test_tunn001_run_with_tunnel(dash_thread_server, fake_cloudflared, caplog): + app = Dash(__name__) + app.layout = html.Div("tunneled", id="out") + + dash_thread_server(app, tunnel=True) + tunnel = app._tunnel + try: + wait_for(lambda: FAKE_URL in caplog.text) + assert tunnel.public_url == FAKE_URL + assert f"Dash is publicly available at {FAKE_URL}/" in caplog.text + + wait_for(fake_cloudflared.exists) + args = json.loads(fake_cloudflared.read_text()) + assert args[args.index("--url") + 1] == dash_thread_server.url.replace( + "localhost", "127.0.0.1" + ) + assert "--no-tls-verify" not in args + + layout = requests.get(f"{dash_thread_server.url}/_dash-layout").json() + assert layout["props"]["children"] == "tunneled" + finally: + tunnel.stop() + assert tunnel.process.poll() is not None + + +@pytest.mark.parametrize("env", [None, "0", "false"]) +def test_tunn002_no_tunnel_when_off(dash_thread_server, monkeypatch, env): + if env is not None: + monkeypatch.setenv("DASH_TUNNEL", env) + app = Dash(__name__) + app.layout = html.Div("local") + dash_thread_server(app) + assert app._tunnel is None + + +def test_tunn003_tunnel_failure_keeps_app_running( + dash_thread_server, monkeypatch, caplog +): + def fail(*_): + raise OSError("no network") + + monkeypatch.setattr("dash._tunnel.find_cloudflared", fail) + monkeypatch.setenv("DASH_TUNNEL", "1") + app = Dash(__name__) + app.layout = html.Div("still local") + dash_thread_server(app) + + assert app._tunnel is None + assert "Could not start the tunnel: no network" in caplog.text + layout = requests.get(f"{dash_thread_server.url}/_dash-layout").json() + assert layout["props"]["children"] == "still local" + + +@pytest.mark.skipif(sys.platform == "win32", reason="no SIGTERM on Windows") +def test_tunn004_sigterm_stops_tunnel(fake_cloudflared, tmp_path): + app_file = tmp_path / "app.py" + app_file.write_text( + textwrap.dedent( + """\ + from dash import Dash, html + app = Dash(__name__) + app.layout = html.Div("bye") + app.run(tunnel=True, port={port}) + """ + ).format(port=free_port()) + ) + pid_file = tmp_path / "pid" + with subprocess.Popen([sys.executable, str(app_file)], cwd=tmp_path) as proc: + try: + wait_for(pid_file.exists) + wait_for(lambda: pid_file.read_text().strip()) + tunnel_pid = int(pid_file.read_text()) + proc.send_signal(signal.SIGTERM) + proc.wait(timeout=10) + finally: + proc.kill() + + def tunnel_gone(): + try: + os.kill(tunnel_pid, 0) + except ProcessLookupError: + return True + return False + + wait_for(tunnel_gone) diff --git a/tests/unit/test_tunnel.py b/tests/unit/test_tunnel.py new file mode 100644 index 0000000000..01ab5d5bf3 --- /dev/null +++ b/tests/unit/test_tunnel.py @@ -0,0 +1,50 @@ +import pytest + +from dash import _tunnel + + +@pytest.mark.parametrize( + "protocol,host,expected", + [ + ("http", "127.0.0.1", "http://127.0.0.1:8050"), + ("http", "0.0.0.0", "http://127.0.0.1:8050"), + ("http", "::", "http://[::1]:8050"), + ("http", "::1", "http://[::1]:8050"), + ("https", "localhost", "https://localhost:8050"), + ], +) +def test_local_url(protocol, host, expected): + assert _tunnel.local_url(protocol, host, 8050) == expected + + +@pytest.mark.parametrize( + "system,machine,expected", + [ + ("darwin", "arm64", "cloudflared-darwin-arm64.tgz"), + ("darwin", "x86_64", "cloudflared-darwin-amd64.tgz"), + ("linux", "x86_64", "cloudflared-linux-amd64"), + ("linux", "aarch64", "cloudflared-linux-arm64"), + ("linux", "i686", None), + ("win32", "AMD64", "cloudflared-windows-amd64.exe"), + ], +) +def test_release_asset(monkeypatch, system, machine, expected): + monkeypatch.setattr(_tunnel.sys, "platform", system) + monkeypatch.setattr(_tunnel.platform, "machine", lambda: machine) + if expected: + assert _tunnel._release_asset() == expected + else: + with pytest.raises(RuntimeError, match="Install cloudflared"): + _tunnel._release_asset() + + +def test_url_regex(): + line = "2026-09-30T12:00:00Z INF | https://a-b-c-1.trycloudflare.com |" + assert _tunnel.TUNNEL_URL_RE.search(line).group(0) == ( + "https://a-b-c-1.trycloudflare.com" + ) + + +def test_url_regex_skips_api_host(): + line = 'ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel"' + assert _tunnel.TUNNEL_URL_RE.search(line) is None From dec2e8b7dc8e6e3318bb3e5cf8f2c961abdbbecb Mon Sep 17 00:00:00 2001 From: Nathan Drezner Date: Wed, 30 Sep 2026 17:02:52 -0400 Subject: [PATCH 2/5] Add PR number to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9ce544f7..b86d9bc1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added -- Add `tunnel=True` to `app.run` (env `DASH_TUNNEL`) to share a locally running app on a public `trycloudflare.com` URL through a Cloudflare quick tunnel. No account needed; `cloudflared` is used from the PATH or downloaded once. For development only. +- [#4028](https://github.com/plotly/dash/pull/4028) Add `tunnel=True` to `app.run` (env `DASH_TUNNEL`) to share a locally running app on a public `trycloudflare.com` URL through a Cloudflare quick tunnel. No account needed; `cloudflared` is used from the PATH or downloaded once. For development only. - [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974). - [#3947](https://github.com/plotly/dash/pull/3947) Make `plotly-cloud` a default install dependency of Dash instead of an optional extra, so the `plotly` CLI and Dash's cloud integration work out of the box. The `dash[cloud]` extra is kept for backward compatibility. - [#3930](https://github.com/plotly/dash/pull/3930) Add shared storage: a backend-agnostic cross-process state manager (key/value with optional TTL, plus ordered replayable pub/sub) on every app via `dash.ctx.shared_storage`, started lazily and disabled with `shared_storage=None`. Ships `LocalSharedStorage` (default, in-memory with optional disk persistence), `DiskcacheSharedStorage`, and `RedisSharedStorage` for horizontally-scaled deployments; see `.ai/ARCHITECTURE.md`. From c20ca7ec89b7882c37cefbdd84476647beaa7bb8 Mon Sep 17 00:00:00 2001 From: Nathan Drezner Date: Wed, 30 Sep 2026 17:06:15 -0400 Subject: [PATCH 3/5] Make downloaded cloudflared executable by owner only --- dash/_tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/_tunnel.py b/dash/_tunnel.py index 1f03e2d472..fce787ed8a 100644 --- a/dash/_tunnel.py +++ b/dash/_tunnel.py @@ -60,7 +60,7 @@ def _download_cloudflared(logger): with tarfile.open(download) as tgz: tgz.extract("cloudflared", tmp, filter="data") download = os.path.join(tmp, exe_name) - os.chmod(download, 0o755) + os.chmod(download, 0o700) # Rename last so a failed download never leaves a broken binary behind. os.replace(download, target) return target From 4f66e10f5205aa19e2449eedcad492c13280e466 Mon Sep 17 00:00:00 2001 From: Nathan Drezner Date: Wed, 30 Sep 2026 18:39:58 -0400 Subject: [PATCH 4/5] Ask before downloading cloudflared, pin it and verify its checksum Also stop the tunnel on SIGHUP so closing the terminal does not leave it running. --- CHANGELOG.md | 2 +- dash/_tunnel.py | 101 ++++++++++++++++++++++--------- dash/dash.py | 3 +- tests/integration/test_tunnel.py | 5 +- tests/unit/test_tunnel.py | 97 +++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b86d9bc1a1..3224412ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added -- [#4028](https://github.com/plotly/dash/pull/4028) Add `tunnel=True` to `app.run` (env `DASH_TUNNEL`) to share a locally running app on a public `trycloudflare.com` URL through a Cloudflare quick tunnel. No account needed; `cloudflared` is used from the PATH or downloaded once. For development only. +- [#4028](https://github.com/plotly/dash/pull/4028) Add `tunnel=True` to `app.run` (env `DASH_TUNNEL`) to share a locally running app on a public `trycloudflare.com` URL through a Cloudflare quick tunnel. No account needed. `cloudflared` is used from the PATH, or, after asking, a pinned release is downloaded and checked against its checksum. For development only. - [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974). - [#3947](https://github.com/plotly/dash/pull/3947) Make `plotly-cloud` a default install dependency of Dash instead of an optional extra, so the `plotly` CLI and Dash's cloud integration work out of the box. The `dash[cloud]` extra is kept for backward compatibility. - [#3930](https://github.com/plotly/dash/pull/3930) Add shared storage: a backend-agnostic cross-process state manager (key/value with optional TTL, plus ordered replayable pub/sub) on every app via `dash.ctx.shared_storage`, started lazily and disabled with `shared_storage=None`. Ships `LocalSharedStorage` (default, in-memory with optional disk persistence), `DiskcacheSharedStorage`, and `RedisSharedStorage` for horizontally-scaled deployments; see `.ai/ARCHITECTURE.md`. diff --git a/dash/_tunnel.py b/dash/_tunnel.py index fce787ed8a..69566ef8c5 100644 --- a/dash/_tunnel.py +++ b/dash/_tunnel.py @@ -1,11 +1,13 @@ """Expose a locally running Dash app through a Cloudflare quick tunnel. Quick tunnels need no Cloudflare account. ``cloudflared`` is used from the -PATH when present, otherwise the official release is downloaded once and -cached. +PATH when present, otherwise a pinned release is downloaded once, after the +user agrees, and checked against its published checksum. """ import atexit +import hashlib +import io import os import platform import re @@ -18,7 +20,24 @@ import threading import urllib.request -RELEASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/" +CLOUDFLARED_VERSION = "2026.9.3" +RELEASE_URL = ( + "https://github.com/cloudflare/cloudflared/releases/download/" + f"{CLOUDFLARED_VERSION}/" +) +# SHA-256 of the cloudflared binary (inside the .tgz on macOS), from the +# release notes. +CLOUDFLARED_SHA256 = { + "cloudflared-darwin-amd64.tgz": "ab588b3b4db9cdb4476c30a3db2a72635b1d8327d44741fee6799a0f37b0ec07", + "cloudflared-darwin-arm64.tgz": "5472c1a01c84bc31b3021056a73b4e5774ddddefc572124ea8fdf6c340639f32", + "cloudflared-linux-amd64": "77e26d8d900e0b8469f416239d14b5f296525fdf79fee6f511ef55609e3fbac2", + "cloudflared-linux-arm64": "aaeb2d7d0da3614634c7e03ab13487a1522c2e79165ed2929cfe23d5e95b326d", + "cloudflared-windows-amd64.exe": "f096265ec2fcbe9bb6e2d64268db167ced3fcbb83d894bdb9e2fcdb26f2ea7e2", +} +INSTALL_HINT = ( + "Install cloudflared and make sure it is on your PATH: " + "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" +) # api.trycloudflare.com shows up in cloudflared's own error messages. TUNNEL_URL_RE = re.compile(r"https://(?!api\.)[-a-z0-9]+\.trycloudflare\.com") @@ -34,35 +53,54 @@ def _release_asset(): if name: return f"cloudflared-{name}-{arch[machine]}{suffix}" raise RuntimeError( - f"No cloudflared build for {sys.platform}/{machine}. Install cloudflared " - "yourself and make sure it is on your PATH: " - "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" + f"No cloudflared build for {sys.platform}/{machine}. {INSTALL_HINT}" ) +def _confirm_download(): + if not (sys.stdin and sys.stdin.isatty()): + raise RuntimeError(f"cloudflared was not found. {INSTALL_HINT}") + try: + answer = input( + f"cloudflared was not found. Download cloudflared {CLOUDFLARED_VERSION} " + "from github.com/cloudflare/cloudflared (about 40 MB)? [y/N] " + ) + except EOFError: + answer = "" + if answer.strip().lower() not in ("y", "yes"): + raise RuntimeError(f"Download declined. {INSTALL_HINT}") + + def _download_cloudflared(logger): asset = _release_asset() exe_name = "cloudflared.exe" if sys.platform == "win32" else "cloudflared" - target_dir = os.path.expanduser(os.path.join("~", ".cache", "dash", "cloudflared")) + target_dir = os.path.expanduser( + os.path.join("~", ".cache", "dash", "cloudflared", CLOUDFLARED_VERSION) + ) target = os.path.join(target_dir, exe_name) if os.path.isfile(target): return target + _confirm_download() os.makedirs(target_dir, exist_ok=True) logger.info("Downloading cloudflared from %s%s", RELEASE_URL, asset) with tempfile.TemporaryDirectory(dir=target_dir) as tmp: - download = os.path.join(tmp, asset) - with urllib.request.urlopen(RELEASE_URL + asset, timeout=60) as resp, open( - download, "wb" - ) as out: - shutil.copyfileobj(resp, out) + binary = os.path.join(tmp, exe_name) + with urllib.request.urlopen(RELEASE_URL + asset, timeout=60) as resp: + data = resp.read() if asset.endswith(".tgz"): - with tarfile.open(download) as tgz: - tgz.extract("cloudflared", tmp, filter="data") - download = os.path.join(tmp, exe_name) - os.chmod(download, 0o700) + with tarfile.open(fileobj=io.BytesIO(data)) as tgz: + member = tgz.extractfile("cloudflared") + data = member.read() if member else b"" + if hashlib.sha256(data).hexdigest() != CLOUDFLARED_SHA256[asset]: + raise RuntimeError( + f"The downloaded {asset} does not match its published checksum." + ) + with open(binary, "wb") as out: + out.write(data) + os.chmod(binary, 0o700) # Rename last so a failed download never leaves a broken binary behind. - os.replace(download, target) + os.replace(binary, target) return target @@ -93,22 +131,25 @@ def stop(self): self.process.kill() -def _stop_on_sigterm(tunnel): - # atexit does not run on SIGTERM, which would leave cloudflared - # exposing the port after Dash is gone. +def _stop_on_signals(tunnel): + # atexit does not run on SIGTERM, or on SIGHUP when the terminal closes, + # which would leave cloudflared exposing the port after Dash is gone. if threading.current_thread() is not threading.main_thread(): return - original = signal.getsignal(signal.SIGTERM) + for signum in (signal.SIGTERM, getattr(signal, "SIGHUP", None)): + if signum is None: + continue + original = signal.getsignal(signum) - def handler(sig, frame): - tunnel.stop() - if callable(original): - original(sig, frame) - elif original == signal.SIG_DFL: - signal.signal(sig, signal.SIG_DFL) - signal.raise_signal(sig) + def handler(sig, frame, original=original): + tunnel.stop() + if callable(original): + original(sig, frame) + elif original == signal.SIG_DFL: + signal.signal(sig, signal.SIG_DFL) + signal.raise_signal(sig) - signal.signal(signal.SIGTERM, handler) + signal.signal(signum, handler) def start_tunnel(target_url, logger, path="/"): @@ -133,7 +174,7 @@ def start_tunnel(target_url, logger, path="/"): ) tunnel = Tunnel(process) atexit.register(tunnel.stop) - _stop_on_sigterm(tunnel) + _stop_on_signals(tunnel) def watch(): output = [] diff --git a/dash/dash.py b/dash/dash.py index 34449916c3..c999893b1d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -2634,7 +2634,8 @@ def run( :param tunnel: Share the app on a public ``trycloudflare.com`` URL through a Cloudflare quick tunnel. No Cloudflare account needed. - Uses ``cloudflared`` from your PATH, or downloads it once if missing. + Uses ``cloudflared`` from your PATH. If it is missing, asks before + downloading a pinned release and checks it against its checksum. Anyone with the URL can reach the app. For development only. env: ``DASH_TUNNEL`` :type tunnel: bool diff --git a/tests/integration/test_tunnel.py b/tests/integration/test_tunnel.py index 56226f56bb..820e354589 100644 --- a/tests/integration/test_tunnel.py +++ b/tests/integration/test_tunnel.py @@ -109,7 +109,8 @@ def fail(*_): @pytest.mark.skipif(sys.platform == "win32", reason="no SIGTERM on Windows") -def test_tunn004_sigterm_stops_tunnel(fake_cloudflared, tmp_path): +@pytest.mark.parametrize("signum", ["SIGTERM", "SIGHUP"]) +def test_tunn004_signal_stops_tunnel(fake_cloudflared, tmp_path, signum): app_file = tmp_path / "app.py" app_file.write_text( textwrap.dedent( @@ -127,7 +128,7 @@ def test_tunn004_sigterm_stops_tunnel(fake_cloudflared, tmp_path): wait_for(pid_file.exists) wait_for(lambda: pid_file.read_text().strip()) tunnel_pid = int(pid_file.read_text()) - proc.send_signal(signal.SIGTERM) + proc.send_signal(getattr(signal, signum)) proc.wait(timeout=10) finally: proc.kill() diff --git a/tests/unit/test_tunnel.py b/tests/unit/test_tunnel.py index 01ab5d5bf3..e67e2394f5 100644 --- a/tests/unit/test_tunnel.py +++ b/tests/unit/test_tunnel.py @@ -1,3 +1,9 @@ +import hashlib +import io +import logging +import os +import tarfile + import pytest from dash import _tunnel @@ -48,3 +54,94 @@ def test_url_regex(): def test_url_regex_skips_api_host(): line = 'ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel"' assert _tunnel.TUNNEL_URL_RE.search(line) is None + + +class FakeStdin: + def __init__(self, tty): + self.tty = tty + + def isatty(self): + return self.tty + + +@pytest.fixture +def fake_release(monkeypatch, tmp_path): + """Serve ``payload`` as the release asset and count the requests.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(_tunnel.sys, "platform", "linux") + monkeypatch.setattr(_tunnel.platform, "machine", lambda: "x86_64") + release = {"payload": b"fake cloudflared binary", "requests": 0} + + def urlopen(url, timeout): + release["requests"] += 1 + return io.BytesIO(release["payload"]) + + monkeypatch.setattr(_tunnel.urllib.request, "urlopen", urlopen) + monkeypatch.setitem( + _tunnel.CLOUDFLARED_SHA256, + "cloudflared-linux-amd64", + hashlib.sha256(release["payload"]).hexdigest(), + ) + return release + + +def answer(monkeypatch, reply, tty=True): + def fake_input(_): + if reply is EOFError: + raise EOFError + return reply + + monkeypatch.setattr(_tunnel.sys, "stdin", FakeStdin(tty)) + monkeypatch.setattr("builtins.input", fake_input) + + +def test_download_after_yes_then_cached(monkeypatch, fake_release): + answer(monkeypatch, "y") + path = _tunnel._download_cloudflared(logging.getLogger()) + with open(path, "rb") as f: + assert f.read() == fake_release["payload"] + assert os.access(path, os.X_OK) + assert _tunnel.CLOUDFLARED_VERSION in path + + answer(monkeypatch, "n") + assert _tunnel._download_cloudflared(logging.getLogger()) == path + assert fake_release["requests"] == 1 + + +@pytest.mark.parametrize( + "reply,tty", [("n", True), ("", True), (EOFError, True), ("y", False)] +) +def test_no_download_without_consent(monkeypatch, fake_release, reply, tty): + answer(monkeypatch, reply, tty) + with pytest.raises(RuntimeError, match="Install cloudflared"): + _tunnel._download_cloudflared(logging.getLogger()) + assert fake_release["requests"] == 0 + + +def test_checksum_mismatch(monkeypatch, fake_release, tmp_path): + answer(monkeypatch, "y") + fake_release["payload"] = b"tampered" + with pytest.raises(RuntimeError, match="checksum"): + _tunnel._download_cloudflared(logging.getLogger()) + assert not list(tmp_path.glob(".cache/dash/cloudflared/*/cloudflared")) + + +def test_download_extracts_macos_archive(monkeypatch, fake_release): + binary = b"fake mac binary" + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as tgz: + info = tarfile.TarInfo("cloudflared") + info.size = len(binary) + tgz.addfile(info, io.BytesIO(binary)) + fake_release["payload"] = archive.getvalue() + monkeypatch.setattr(_tunnel.sys, "platform", "darwin") + monkeypatch.setattr(_tunnel.platform, "machine", lambda: "arm64") + monkeypatch.setitem( + _tunnel.CLOUDFLARED_SHA256, + "cloudflared-darwin-arm64.tgz", + hashlib.sha256(binary).hexdigest(), + ) + answer(monkeypatch, "yes") + with open(_tunnel._download_cloudflared(logging.getLogger()), "rb") as f: + assert f.read() == binary From 0b4ee1b203ffb7bebd11883c90390e238ed42619 Mon Sep 17 00:00:00 2001 From: Nathan Drezner Date: Wed, 30 Sep 2026 18:56:32 -0400 Subject: [PATCH 5/5] Keep only the call under test inside pytest.raises --- tests/unit/test_tunnel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_tunnel.py b/tests/unit/test_tunnel.py index e67e2394f5..d3da748e49 100644 --- a/tests/unit/test_tunnel.py +++ b/tests/unit/test_tunnel.py @@ -114,16 +114,18 @@ def test_download_after_yes_then_cached(monkeypatch, fake_release): ) def test_no_download_without_consent(monkeypatch, fake_release, reply, tty): answer(monkeypatch, reply, tty) + logger = logging.getLogger() with pytest.raises(RuntimeError, match="Install cloudflared"): - _tunnel._download_cloudflared(logging.getLogger()) + _tunnel._download_cloudflared(logger) assert fake_release["requests"] == 0 def test_checksum_mismatch(monkeypatch, fake_release, tmp_path): answer(monkeypatch, "y") fake_release["payload"] = b"tampered" + logger = logging.getLogger() with pytest.raises(RuntimeError, match="checksum"): - _tunnel._download_cloudflared(logging.getLogger()) + _tunnel._download_cloudflared(logger) assert not list(tmp_path.glob(".cache/dash/cloudflared/*/cloudflared"))