* studio: announce Cloudflare tunnel state and warn about public exposure on startup The startup banner only printed a line when a tunnel URL was up, so a plain `unsloth studio -H 0.0.0.0` launch silently created a public trycloudflare.com URL with no indication that Studio had become reachable from the internet. The only hint at the tunnel was the CLI help, shown when an invalid command was typed. Make the banner always state the tunnel state for wildcard binds: - ON: the public URL plus a warning that anyone with it can reach Studio from outside the network, and that --no-cloudflare keeps it local-only. - FAILED: requested but did not start (local network only). - OFF: --no-cloudflare was passed (local network only). Secure mode keeps its existing wording (the authenticated tunnel is intended and --no-cloudflare is not valid there). Clarify the --cloudflare help text in both the argparse and typer definitions. Default behavior is unchanged. Also surface the state on the `unsloth studio run` banner, which runs the server with silent=True and prints its own banner: it now calls _print_cloudflare_line too, so the ON/OFF/FAILED notice and public-exposure warning are no longer skipped on that path (previously it only echoed the URL when a tunnel was up). For the OFF and FAILED notices, do not claim "local network only" when the reachability probe just confirmed the raw port is reachable from the public internet: --no-cloudflare and a failed tunnel disable only the Cloudflare link, not the wildcard bind, so the message is reworded to flag the public raw port. * Fix/adjust Cloudflare banner warnings for PR #6515 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix/adjust Cloudflare banner comments for PR #6515 * Fix/adjust IPv6 Cloudflare tunnel gate for PR #6515 * Fix/adjust Cloudflare review comments for PR #6515 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix silent run Cloudflare notice * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
922 lines
31 KiB
Python
922 lines
31 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the Cloudflare quick-tunnel helper and run.py wiring.
|
|
|
|
cloudflare_tunnel.py is stdlib-only (storage_roots is imported lazily), so it
|
|
loads via spec_from_file_location without the studio venv. run.py defaults are
|
|
checked by AST so we never import its heavy deps (uvicorn/structlog).
|
|
"""
|
|
|
|
import ast
|
|
import importlib.util
|
|
import io
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND = Path(__file__).resolve().parent.parent
|
|
_CT_PY = _BACKEND / "cloudflare_tunnel.py"
|
|
_RUN_PY = _BACKEND / "run.py"
|
|
|
|
|
|
def _load_ct():
|
|
spec = importlib.util.spec_from_file_location("cloudflare_tunnel", _CT_PY)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
ct = _load_ct()
|
|
|
|
|
|
# ── URL parsing ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_url_regex_extracts_and_ignores_noise():
|
|
blob = (
|
|
"2026-06-11T10:00:00Z INF Thank you for trying Cloudflare Tunnel.\n"
|
|
"2026-06-11T10:00:01Z INF Requesting new quick Tunnel on trycloudflare.com...\n"
|
|
"2026-06-11T10:00:01Z INF | https://setting-democracy-gathering.trycloudflare.com |\n"
|
|
"2026-06-11T10:00:02Z INF Registered tunnel connection https://not-the-url.example.com\n"
|
|
)
|
|
m = ct._URL_RE.search(blob)
|
|
assert m is not None
|
|
assert m.group(0) == "https://setting-democracy-gathering.trycloudflare.com"
|
|
|
|
|
|
def test_url_regex_no_match_on_unrelated():
|
|
assert ct._URL_RE.search("INF connecting to https://api.cloudflare.com/v4") is None
|
|
|
|
|
|
def test_url_regex_ignores_api_endpoint():
|
|
# cloudflared's failure line names its own API host; it must never be taken
|
|
# as the tunnel URL (it returns a 404 and is not a quick tunnel).
|
|
line = (
|
|
'failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": '
|
|
"context deadline exceeded"
|
|
)
|
|
assert ct._URL_RE.search(line) is None
|
|
|
|
|
|
def test_url_regex_skips_api_host_but_matches_real_url():
|
|
blob = (
|
|
'ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel"\n'
|
|
"INF | https://brave-mountain-river-clouds.trycloudflare.com |\n"
|
|
)
|
|
m = ct._URL_RE.search(blob)
|
|
assert m is not None
|
|
assert m.group(0) == "https://brave-mountain-river-clouds.trycloudflare.com"
|
|
|
|
|
|
# ── asset mapping ────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"system,machine,expected",
|
|
[
|
|
("Linux", "x86_64", ("cloudflared-linux-amd64", False)),
|
|
("Linux", "aarch64", ("cloudflared-linux-arm64", False)),
|
|
("Darwin", "arm64", ("cloudflared-darwin-arm64.tgz", True)),
|
|
("Darwin", "x86_64", ("cloudflared-darwin-amd64.tgz", True)),
|
|
("Windows", "AMD64", ("cloudflared-windows-amd64.exe", False)),
|
|
("Windows", "x86", ("cloudflared-windows-386.exe", False)),
|
|
("Linux", "mips", None),
|
|
("Plan9", "x86_64", None),
|
|
],
|
|
)
|
|
def test_asset_name(monkeypatch, system, machine, expected):
|
|
monkeypatch.setattr(ct.platform, "system", lambda: system)
|
|
monkeypatch.setattr(ct.platform, "machine", lambda: machine)
|
|
assert ct._asset_name() == expected
|
|
|
|
|
|
# ── binary discovery ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_find_cloudflared_prefers_path(monkeypatch):
|
|
monkeypatch.setattr(ct.shutil, "which", lambda name: "/usr/local/bin/cloudflared")
|
|
assert ct.find_cloudflared() == "/usr/local/bin/cloudflared"
|
|
|
|
|
|
def test_find_cloudflared_falls_back_to_cache(monkeypatch, tmp_path):
|
|
cached = tmp_path / "cloudflared"
|
|
cached.write_text("#!/bin/sh\n")
|
|
cached.chmod(0o755)
|
|
monkeypatch.setattr(ct.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
|
|
assert ct.find_cloudflared() == str(cached)
|
|
|
|
|
|
def test_find_cloudflared_none_when_missing(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(ct.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "absent")
|
|
assert ct.find_cloudflared() is None
|
|
|
|
|
|
# ── ensure / download ────────────────────────────────────────────────
|
|
|
|
|
|
def test_ensure_downloads_and_chmods_when_missing(monkeypatch, tmp_path):
|
|
cached = tmp_path / "cloudflared"
|
|
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
|
|
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-linux-amd64", False))
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
|
|
|
|
def fake_download(url, dest):
|
|
assert url.endswith("/cloudflared-linux-amd64")
|
|
dest.write_bytes(b"ELF-ish")
|
|
return True
|
|
|
|
monkeypatch.setattr(ct, "_download", fake_download)
|
|
monkeypatch.setattr(ct.sys, "platform", "linux")
|
|
path = ct.ensure_cloudflared()
|
|
assert path == str(cached)
|
|
assert cached.exists()
|
|
# Host OS, not monkeypatched ct.sys.platform.
|
|
if os.name != "nt":
|
|
assert cached.stat().st_mode & 0o111
|
|
|
|
|
|
def test_ensure_returns_none_on_download_failure(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
|
|
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-linux-amd64", False))
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "cloudflared")
|
|
monkeypatch.setattr(ct, "_download", lambda url, dest: False)
|
|
assert ct.ensure_cloudflared() is None
|
|
|
|
|
|
def test_ensure_returns_none_for_unsupported_arch(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
|
|
monkeypatch.setattr(ct, "_asset_name", lambda: None)
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "cloudflared")
|
|
assert ct.ensure_cloudflared() is None
|
|
|
|
|
|
def test_download_sets_user_agent(monkeypatch, tmp_path):
|
|
import urllib.request
|
|
|
|
captured = {}
|
|
|
|
class _Resp:
|
|
_sent = False
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self, n = -1):
|
|
if self._sent:
|
|
return b""
|
|
self._sent = True
|
|
return b"payload"
|
|
|
|
def fake_urlopen(req, timeout = None):
|
|
captured["ua"] = req.get_header("User-agent")
|
|
return _Resp()
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
dest = tmp_path / "cloudflared"
|
|
assert ct._download("https://github.com/cloudflare/cloudflared/x", dest) is True
|
|
assert captured["ua"] == "unsloth-studio" # GitHub CDN 403s the default UA
|
|
assert dest.read_bytes() == b"payload"
|
|
|
|
|
|
# ── cross-platform: Windows (.exe), macOS (.tgz) ─────────────────────
|
|
|
|
|
|
def test_cache_path_uses_exe_on_windows(monkeypatch, tmp_path):
|
|
import types
|
|
|
|
fake_sr = types.ModuleType("utils.paths.storage_roots")
|
|
fake_sr.studio_bin_root = lambda: tmp_path
|
|
monkeypatch.setitem(sys.modules, "utils.paths.storage_roots", fake_sr)
|
|
monkeypatch.setattr(ct.sys, "platform", "win32")
|
|
assert ct._cache_path() == tmp_path / "cloudflared.exe"
|
|
|
|
|
|
def test_ensure_windows_downloads_exe(monkeypatch, tmp_path):
|
|
cached = tmp_path / "cloudflared.exe"
|
|
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
|
|
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-windows-amd64.exe", False))
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
|
|
monkeypatch.setattr(ct.sys, "platform", "win32")
|
|
|
|
def fake_download(url, dest):
|
|
assert url.endswith("/cloudflared-windows-amd64.exe")
|
|
dest.write_bytes(b"MZ") # PE header magic
|
|
return True
|
|
|
|
monkeypatch.setattr(ct, "_download", fake_download)
|
|
# chmod is skipped on Windows; would raise on a path that does not exist yet.
|
|
monkeypatch.setattr(ct.os, "chmod", lambda *a, **k: pytest.fail("chmod called on win32"))
|
|
assert ct.ensure_cloudflared() == str(cached)
|
|
assert cached.read_bytes() == b"MZ"
|
|
|
|
|
|
def test_ensure_macos_extracts_tgz_and_chmods(monkeypatch, tmp_path):
|
|
cached = tmp_path / "cloudflared"
|
|
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
|
|
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-darwin-arm64.tgz", True))
|
|
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
|
|
monkeypatch.setattr(ct.sys, "platform", "darwin")
|
|
|
|
def fake_download(url, dest):
|
|
# dest is cached.with_suffix(".tgz"); write a real archive there.
|
|
assert url.endswith("/cloudflared-darwin-arm64.tgz")
|
|
with tarfile.open(dest, "w:gz") as tar:
|
|
data = b"mach-o"
|
|
info = tarfile.TarInfo(name = "cloudflared")
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
return True
|
|
|
|
monkeypatch.setattr(ct, "_download", fake_download)
|
|
path = ct.ensure_cloudflared()
|
|
assert path == str(cached)
|
|
assert cached.read_bytes() == b"mach-o"
|
|
if os.name != "nt":
|
|
assert cached.stat().st_mode & 0o111
|
|
assert not cached.with_suffix(".tgz").exists() # temp archive cleaned up
|
|
|
|
|
|
# ── .tgz extraction (darwin) ─────────────────────────────────────────
|
|
|
|
|
|
def _make_tgz(
|
|
tmp_path,
|
|
member_name,
|
|
data = b"bin",
|
|
):
|
|
tgz = tmp_path / "cf.tgz"
|
|
with tarfile.open(tgz, "w:gz") as tar:
|
|
info = tarfile.TarInfo(name = member_name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
return tgz
|
|
|
|
|
|
def test_tgz_extraction_extracts_clean_member(tmp_path):
|
|
tgz = _make_tgz(tmp_path, "cloudflared")
|
|
dest = tmp_path / "out"
|
|
assert ct._extract_tgz_member(tgz, dest) is True
|
|
assert dest.read_bytes() == b"bin"
|
|
|
|
|
|
def test_tgz_extraction_rejects_traversal(tmp_path):
|
|
tgz = _make_tgz(tmp_path, "../cloudflared")
|
|
dest = tmp_path / "out"
|
|
assert ct._extract_tgz_member(tgz, dest) is False
|
|
assert not dest.exists()
|
|
|
|
|
|
def test_tgz_extraction_missing_member(tmp_path):
|
|
tgz = _make_tgz(tmp_path, "README")
|
|
dest = tmp_path / "out"
|
|
assert ct._extract_tgz_member(tgz, dest) is False
|
|
|
|
|
|
# ── tunnel lifecycle ─────────────────────────────────────────────────
|
|
|
|
|
|
class _FakePopen:
|
|
def __init__(self):
|
|
self.terminated = False
|
|
self.killed = False
|
|
self._alive = True
|
|
|
|
def poll(self):
|
|
return None if self._alive else 0
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
self._alive = False
|
|
|
|
def wait(self, timeout = None):
|
|
if self._alive:
|
|
raise ct.subprocess.TimeoutExpired(cmd = "cloudflared", timeout = timeout)
|
|
return 0
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
self._alive = False
|
|
|
|
|
|
def test_stop_terminates_process():
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
fake = _FakePopen()
|
|
t._proc = fake
|
|
t.stop()
|
|
assert fake.terminated is True
|
|
assert t._proc is None
|
|
# second stop is a no-op (idempotent)
|
|
t.stop()
|
|
|
|
|
|
def test_start_after_stop_does_not_spawn(monkeypatch):
|
|
# If stop() lands before start() (a concurrent shutdown in the caller's
|
|
# register->start window), start() must NOT spawn a cloudflared process --
|
|
# nobody would own it and it would be orphaned.
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
spawned = []
|
|
|
|
class _FakeProc:
|
|
stdout = None
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
monkeypatch.setattr(ct.subprocess, "Popen", lambda *a, **k: (spawned.append(a), _FakeProc())[1])
|
|
t.stop() # proc is None -> no-op terminate, but marks the tunnel stopped
|
|
t.start() # must short-circuit before Popen
|
|
assert spawned == []
|
|
assert t._proc is None
|
|
|
|
|
|
def test_wait_for_ready_times_out_without_blocking():
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
assert t.wait_for_ready(timeout = 0.05) is None
|
|
|
|
|
|
def _fake_proc(text):
|
|
return types.SimpleNamespace(stdout = io.StringIO(text))
|
|
|
|
|
|
def test_reader_captures_url_and_registration():
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
t._reader(
|
|
_fake_proc(
|
|
"INF Requesting new quick Tunnel on trycloudflare.com...\n"
|
|
"INF | https://words-here-abc.trycloudflare.com |\n"
|
|
"INF Registered tunnel connection connIndex=0 protocol=http2\n"
|
|
)
|
|
)
|
|
assert t.url == "https://words-here-abc.trycloudflare.com"
|
|
assert t.ready is True
|
|
assert t.wait_for_ready(0) == t.url
|
|
assert t.error is None # a fully-registered tunnel records no error
|
|
|
|
|
|
def test_reader_url_without_registration_is_not_ready():
|
|
# A URL but no "Registered tunnel connection" (e.g. quic control stream
|
|
# fails) must not be advertised -- it returns Cloudflare error 1033.
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
t._reader(
|
|
_fake_proc(
|
|
"INF | https://words-here-abc.trycloudflare.com |\n"
|
|
'ERR failed to serve tunnel connection error="control stream failure"\n'
|
|
)
|
|
)
|
|
assert t.url == "https://words-here-abc.trycloudflare.com"
|
|
assert t.ready is False
|
|
assert t.wait_for_ready(0) is None
|
|
assert t.error == "cloudflared exited before the tunnel connection registered"
|
|
|
|
|
|
def test_reader_handles_none_stdout():
|
|
# Popen.stdout can be None; _reader must not crash and must leave the tunnel
|
|
# un-ready so wait_for_ready returns None.
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
t._reader(types.SimpleNamespace(stdout = None))
|
|
assert t.url is None
|
|
assert t.ready is False
|
|
assert t.wait_for_ready(0) is None
|
|
assert t.error == "cloudflared exited before emitting a tunnel URL"
|
|
|
|
|
|
def test_reader_ignores_api_endpoint_failure_line():
|
|
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
|
|
t._reader(
|
|
_fake_proc(
|
|
"ERR failed to request quick Tunnel: Post "
|
|
'"https://api.trycloudflare.com/tunnel": context deadline exceeded\n'
|
|
)
|
|
)
|
|
assert t.url is None
|
|
assert t.wait_for_ready(0) is None
|
|
assert t.error == "cloudflared exited before emitting a tunnel URL"
|
|
|
|
|
|
def test_start_studio_tunnel_no_binary(monkeypatch):
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: None)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
|
|
|
|
def test_start_studio_tunnel_registers_before_wait(monkeypatch):
|
|
# The tunnel must be visible to stop_studio_tunnel() during the readiness
|
|
# wait, else a shutdown in that window orphans cloudflared.
|
|
seen = {}
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def wait_for_ready(self, timeout):
|
|
seen["active_during_wait"] = ct._active_tunnel is self
|
|
self.url = "https://x.trycloudflare.com"
|
|
return self.url
|
|
|
|
def stop(self):
|
|
seen["stopped"] = True
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
try:
|
|
assert ct.start_studio_tunnel(8080) == "https://x.trycloudflare.com"
|
|
assert seen["active_during_wait"] is True
|
|
finally:
|
|
ct.stop_studio_tunnel()
|
|
|
|
|
|
def test_start_studio_tunnel_clears_and_stops_on_no_url(monkeypatch):
|
|
seen = {}
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return None
|
|
|
|
def stop(self):
|
|
seen["stopped"] = True
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
assert seen.get("stopped") is True
|
|
assert ct._active_tunnel is None
|
|
|
|
|
|
def test_start_studio_tunnel_returns_url(monkeypatch):
|
|
class _StubTunnel:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
|
|
def start(self):
|
|
self.url = "https://stub-xyz.trycloudflare.com"
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return self.url
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _StubTunnel)
|
|
try:
|
|
assert ct.start_studio_tunnel(8080) == "https://stub-xyz.trycloudflare.com"
|
|
finally:
|
|
ct.stop_studio_tunnel()
|
|
|
|
|
|
def test_start_studio_tunnel_falls_back_to_http2(monkeypatch):
|
|
# First attempt mints a URL but never registers (quic blocked); the http2
|
|
# retry registers and wins.
|
|
attempts = []
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.protocol = protocol
|
|
self.url = None
|
|
attempts.append(protocol)
|
|
|
|
def start(self):
|
|
self.url = "https://words.trycloudflare.com" # URL always minted
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return self.url if self.protocol == "http2" else None
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
try:
|
|
assert ct.start_studio_tunnel(8080) == "https://words.trycloudflare.com"
|
|
assert attempts == [None, "http2"] # default first, then forced http2
|
|
finally:
|
|
ct.stop_studio_tunnel()
|
|
|
|
|
|
def test_start_studio_tunnel_no_retry_when_shutdown_between_attempts(monkeypatch):
|
|
# A stop() landing in the gap AFTER the failed first attempt is cleaned up but
|
|
# BEFORE the http2 retry registers must abort the loop -- not start a second
|
|
# tunnel that nobody will ever stop (Codex review). Simulated by having the
|
|
# first attempt's stop() (called during cleanup) trigger the shutdown.
|
|
attempts = []
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
attempts.append(protocol)
|
|
|
|
def start(self):
|
|
self.url = "https://words.trycloudflare.com" # URL minted, never ready
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return None
|
|
|
|
def stop(self):
|
|
ct.stop_studio_tunnel() # a concurrent shutdown lands in the gap
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
assert attempts == [None] # http2 retry aborted after shutdown
|
|
assert ct._active_tunnel is None
|
|
|
|
|
|
def test_start_studio_tunnel_no_http2_retry_when_no_url(monkeypatch):
|
|
# No URL at all is an API/network failure; the http2 fallback would not help,
|
|
# so it must be skipped (don't burn a second timeout window).
|
|
attempts = []
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
attempts.append(protocol)
|
|
|
|
def start(self):
|
|
pass # never mints a URL
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return None
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
assert attempts == [None]
|
|
|
|
|
|
def test_start_studio_tunnel_both_protocols_fail_registration(monkeypatch):
|
|
# Both quic and http2 mint a URL but neither registers -> both attempts are
|
|
# exhausted and None is returned (no dead URL advertised).
|
|
attempts = []
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
attempts.append(protocol)
|
|
|
|
def start(self):
|
|
self.url = "https://words.trycloudflare.com" # URL minted, never ready
|
|
|
|
def wait_for_ready(self, timeout):
|
|
return None
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
assert attempts == [None, "http2"]
|
|
assert ct._active_tunnel is None
|
|
|
|
|
|
def test_start_studio_tunnel_aborts_retry_on_concurrent_shutdown(monkeypatch):
|
|
# If a concurrent stop_studio_tunnel() clears _active_tunnel while we wait,
|
|
# the retry loop must NOT start a second (http2) tunnel: shutdown is already
|
|
# done, so nothing would ever stop it and it would be orphaned.
|
|
attempts = []
|
|
|
|
class _Stub:
|
|
def __init__(
|
|
self,
|
|
port,
|
|
binary,
|
|
protocol = None,
|
|
):
|
|
self.url = None
|
|
attempts.append(protocol)
|
|
|
|
def start(self):
|
|
self.url = "https://words.trycloudflare.com" # URL minted (saw_url True)
|
|
|
|
def wait_for_ready(self, timeout):
|
|
# Simulate stop_studio_tunnel() landing during the wait.
|
|
with ct._active_lock:
|
|
ct._active_tunnel = None
|
|
return None # never registered
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
|
|
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
|
|
assert ct.start_studio_tunnel(8080) is None
|
|
assert attempts == [None] # no http2 retry -> no orphaned second tunnel
|
|
assert ct._active_tunnel is None
|
|
|
|
|
|
# ── run.py source-level pins (AST / source, no heavy import) ─────────
|
|
|
|
|
|
def _func_param_defaults(source, func_name):
|
|
tree = ast.parse(source)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
|
args = node.args.args
|
|
defaults = node.args.defaults
|
|
offset = len(args) - len(defaults)
|
|
out = {}
|
|
for i, d in enumerate(defaults):
|
|
if isinstance(d, ast.Constant):
|
|
out[args[offset + i].arg] = d.value
|
|
return out
|
|
return {}
|
|
|
|
|
|
def _argparse_default(source, option):
|
|
tree = ast.parse(source)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
if node.func.attr == "add_argument" and node.args:
|
|
a0 = node.args[0]
|
|
if isinstance(a0, ast.Constant) and a0.value == option:
|
|
for kw in node.keywords:
|
|
if kw.arg == "default" and isinstance(kw.value, ast.Constant):
|
|
return kw.value.value
|
|
return None
|
|
|
|
|
|
def test_run_server_cloudflare_default_true():
|
|
defaults = _func_param_defaults(_RUN_PY.read_text(), "run_server")
|
|
assert defaults.get("cloudflare") is True
|
|
|
|
|
|
def test_argparse_cloudflare_default_true():
|
|
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is True
|
|
|
|
|
|
def test_verify_global_reachability_marks_private_address_unreachable():
|
|
src = _RUN_PY.read_text()
|
|
tree = ast.parse(src)
|
|
func_src = next(
|
|
ast.get_source_segment(src, n)
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, ast.FunctionDef) and n.name == "_verify_global_reachability"
|
|
)
|
|
captured = []
|
|
ns = {
|
|
"_public_reachable": None,
|
|
"_stdout_color_ok": lambda: False,
|
|
"_url_host": lambda host: host,
|
|
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
|
|
}
|
|
exec(compile(func_src, "<verify_global_reachability>", "exec"), ns)
|
|
ns["_verify_global_reachability"]("192.168.1.10", 8888)
|
|
|
|
assert ns["_public_reachable"] is False
|
|
assert "private/LAN address" in "\n".join(captured)
|
|
|
|
|
|
def test_run_server_registers_tunnel_atexit_backstop():
|
|
# An abnormal exit (exception after startup -> sys.exit) bypasses
|
|
# _graceful_shutdown; an atexit backstop must still stop the tunnel.
|
|
src = _RUN_PY.read_text()
|
|
assert "atexit.register(stop_studio_tunnel)" in src
|
|
|
|
|
|
def _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
*,
|
|
cloudflare_url,
|
|
public_reachable,
|
|
cloudflare_requested = False,
|
|
cloudflare_flag = True,
|
|
secure = False,
|
|
loopback_host = "127.0.0.1",
|
|
color = False,
|
|
):
|
|
"""Exec _print_cloudflare_line without importing run.py's heavy deps."""
|
|
src = _RUN_PY.read_text()
|
|
tree = ast.parse(src)
|
|
func_src = next(
|
|
ast.get_source_segment(src, n)
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, ast.FunctionDef) and n.name == "_print_cloudflare_line"
|
|
)
|
|
stub = types.ModuleType("startup_banner")
|
|
stub.stdout_supports_color = lambda: color
|
|
monkeypatch.setitem(sys.modules, "startup_banner", stub)
|
|
captured: list[str] = []
|
|
ns = {
|
|
"_cloudflare_url": cloudflare_url,
|
|
"_public_reachable": public_reachable,
|
|
"_cloudflare_requested": cloudflare_requested,
|
|
"_cloudflare_flag": cloudflare_flag,
|
|
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
|
|
}
|
|
exec(compile(func_src, "<print_cloudflare_line>", "exec"), ns)
|
|
ns["_print_cloudflare_line"](secure = secure, loopback_host = loopback_host)
|
|
return "\n".join(captured)
|
|
|
|
|
|
def test_cloudflare_line_reworded_when_public_unreachable(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = False
|
|
)
|
|
assert "Use the secure link access via Cloudflare instead: https://x.trycloudflare.com" in out
|
|
|
|
|
|
def test_cloudflare_line_default_wording_when_reachable(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = True
|
|
)
|
|
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
|
assert "Use the secure link" not in out
|
|
|
|
|
|
def test_cloudflare_line_default_wording_when_unknown(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = None
|
|
)
|
|
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
|
assert "Use the secure link" not in out
|
|
|
|
|
|
def test_cloudflare_line_states_inactive_when_enabled_but_not_requested(monkeypatch):
|
|
out = _run_print_cloudflare_line(monkeypatch, cloudflare_url = None, public_reachable = False)
|
|
assert "Cloudflare tunnel: OFF for this mode" in out
|
|
assert "local network only" in out
|
|
|
|
|
|
def test_cloudflare_line_warns_when_public_url_up(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = "https://x.trycloudflare.com",
|
|
public_reachable = True,
|
|
cloudflare_requested = True,
|
|
)
|
|
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
|
assert "Cloudflare tunnel: ON" in out
|
|
assert "PUBLIC" in out
|
|
assert "--no-cloudflare" in out
|
|
assert "raw port is also publicly reachable" in out
|
|
assert "local network only" not in out
|
|
|
|
|
|
def test_cloudflare_line_secure_mode_suppresses_public_warning(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = "https://x.trycloudflare.com",
|
|
public_reachable = True,
|
|
cloudflare_requested = True,
|
|
secure = True,
|
|
)
|
|
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
|
|
assert "Cloudflare tunnel: ON" not in out
|
|
|
|
|
|
def test_cloudflare_line_states_disabled_when_off(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = False,
|
|
cloudflare_requested = False,
|
|
cloudflare_flag = False,
|
|
)
|
|
assert "Cloudflare tunnel: OFF" in out
|
|
assert "local network only" in out
|
|
|
|
|
|
def test_cloudflare_line_states_failed_when_requested_but_no_url(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = False,
|
|
cloudflare_requested = True,
|
|
cloudflare_flag = True,
|
|
)
|
|
assert "requested but failed to start" in out
|
|
assert "local network only" in out
|
|
|
|
|
|
def test_cloudflare_line_off_does_not_claim_local_only_when_unknown(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = None,
|
|
cloudflare_requested = False,
|
|
cloudflare_flag = False,
|
|
)
|
|
assert "Cloudflare tunnel: OFF" in out
|
|
assert "Raw port reachability was not verified" in out
|
|
assert "local network only" not in out
|
|
|
|
|
|
def test_cloudflare_line_failed_does_not_claim_local_only_when_unknown(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = None,
|
|
cloudflare_requested = True,
|
|
cloudflare_flag = True,
|
|
)
|
|
assert "requested but failed to start" in out
|
|
assert "Raw port reachability was not verified" in out
|
|
assert "local network only" not in out
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cloudflare_requested,cloudflare_flag,expected",
|
|
[
|
|
(True, True, "requested but failed to start"),
|
|
(False, True, "Cloudflare tunnel: OFF for this mode"),
|
|
(False, False, "Cloudflare tunnel: OFF"),
|
|
],
|
|
)
|
|
def test_cloudflare_line_unknown_warns_with_loopback_host(
|
|
monkeypatch, cloudflare_requested, cloudflare_flag, expected
|
|
):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = None,
|
|
cloudflare_requested = cloudflare_requested,
|
|
cloudflare_flag = cloudflare_flag,
|
|
loopback_host = "::1",
|
|
color = True,
|
|
)
|
|
assert expected in out
|
|
assert "bind ::1" in out
|
|
assert "bind 127.0.0.1" not in out
|
|
assert "\033[38;5;215;1m" in out
|
|
|
|
|
|
def test_cloudflare_line_off_does_not_claim_local_only_when_publicly_reachable(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = True,
|
|
cloudflare_requested = False,
|
|
cloudflare_flag = False,
|
|
)
|
|
assert "Cloudflare tunnel: OFF" in out
|
|
assert "reachable from the public internet" in out
|
|
assert "local network only" not in out
|
|
|
|
|
|
def test_cloudflare_line_failed_does_not_claim_local_only_when_publicly_reachable(monkeypatch):
|
|
out = _run_print_cloudflare_line(
|
|
monkeypatch,
|
|
cloudflare_url = None,
|
|
public_reachable = True,
|
|
cloudflare_requested = True,
|
|
cloudflare_flag = True,
|
|
)
|
|
assert "requested but failed to start" in out
|
|
assert "reachable from the public internet" in out
|
|
assert "local network only" not in out
|