Skip to content

Commit 54d44c2

Browse files
Zawwarsami16claude
andcommitted
phase 0.2: cloudflared auto-tunnel for hub --public-tunnel flag
zhub-server --public-tunnel spawns ephemeral cloudflared tunnel, prints the generated trycloudflare.com URL. operator runs hub on laptop / phone, gets public URL without VPS or cloudflare account. - zhub/tunnel.py: CloudflareTunnel async wrapper around cloudflared subprocess. is_available() probe. start() reads stdout/stderr until it matches the trycloudflare.com URL pattern, returns the URL or raises after timeout. close() terminates the subprocess. - zhub/server.py: --public-tunnel CLI flag. graceful fallback when cloudflared isn't on PATH. server runs inside an asyncio task so the tunnel's process can be torn down cleanly on shutdown. usage: pip install 'zhub[server]' zhub-server --public-tunnel # ===================================== # zhub public URL: https://random-words.trycloudflare.com # ===================================== publishers point to that public URL. friend's curl from anywhere works. no port forwarding, no VPS, no cloudflare account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 21c1796 commit 54d44c2

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

zhub/server.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,47 @@ def main() -> None:
407407
parser.add_argument("--host", default="0.0.0.0")
408408
parser.add_argument("--port", default=8080, type=int)
409409
parser.add_argument("--log-level", default="info")
410+
parser.add_argument(
411+
"--public-tunnel",
412+
action="store_true",
413+
help="Spawn an ephemeral Cloudflare Tunnel via `cloudflared` and print the public URL.",
414+
)
410415
args = parser.parse_args()
411416

412417
logging.basicConfig(
413418
level=getattr(logging, args.log_level.upper(), logging.INFO),
414419
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
415420
)
421+
422+
if args.public_tunnel:
423+
from .tunnel import CloudflareTunnel
424+
if not CloudflareTunnel.is_available():
425+
print(
426+
"warning: --public-tunnel requested but `cloudflared` not found on PATH.\n"
427+
" install: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n"
428+
" falling back to localhost-only mode."
429+
)
430+
else:
431+
async def _run_with_tunnel() -> None:
432+
tunnel = CloudflareTunnel(local_port=args.port)
433+
try:
434+
url = await tunnel.start()
435+
print()
436+
print("=" * 60)
437+
print(f" zhub public URL: {url}")
438+
print("=" * 60)
439+
print()
440+
config = uvicorn.Config(
441+
create_app(), host=args.host, port=args.port,
442+
log_level=args.log_level,
443+
)
444+
server = uvicorn.Server(config)
445+
await server.serve()
446+
finally:
447+
await tunnel.close()
448+
asyncio.run(_run_with_tunnel())
449+
return
450+
416451
app = create_app()
417452
uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
418453

zhub/tunnel.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Cloudflare Tunnel auto-config helper for the hub.
3+
4+
If `cloudflared` is on the PATH and `--public-tunnel` is passed to the hub,
5+
the hub spawns an ephemeral cloudflared tunnel on startup and prints the
6+
public URL to stdout. No Cloudflare account required for ephemeral tunnels.
7+
8+
For production deployment with a stable URL, use a named cloudflared tunnel
9+
(see the cloudflared docs) and front the hub behind it the normal way —
10+
this helper is for laptop / phone-side dev, not production.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import logging
17+
import re
18+
import shutil
19+
from typing import Optional
20+
21+
log = logging.getLogger("zhub.tunnel")
22+
23+
URL_RE = re.compile(rb"https://[A-Za-z0-9.-]+\.trycloudflare\.com")
24+
25+
26+
class CloudflareTunnel:
27+
"""Wraps a cloudflared subprocess. Starts on entry, terminates on close()."""
28+
29+
def __init__(self, local_port: int, binary: Optional[str] = None) -> None:
30+
self.local_port = local_port
31+
self.binary = binary or shutil.which("cloudflared")
32+
self.process: Optional[asyncio.subprocess.Process] = None
33+
self.public_url: Optional[str] = None
34+
35+
@classmethod
36+
def is_available(cls) -> bool:
37+
return shutil.which("cloudflared") is not None
38+
39+
async def start(self, timeout: float = 30.0) -> str:
40+
"""Start the tunnel. Returns the public URL when it's ready."""
41+
if not self.binary:
42+
raise RuntimeError(
43+
"cloudflared not found on PATH. install from "
44+
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
45+
)
46+
cmd = [self.binary, "tunnel", "--url", f"http://localhost:{self.local_port}", "--no-autoupdate"]
47+
log.info("starting cloudflared: %s", " ".join(cmd))
48+
self.process = await asyncio.create_subprocess_exec(
49+
*cmd,
50+
stdout=asyncio.subprocess.PIPE,
51+
stderr=asyncio.subprocess.STDOUT,
52+
)
53+
# Read stdout/stderr until we see the URL or timeout
54+
url_future: asyncio.Future = asyncio.get_running_loop().create_future()
55+
56+
async def consume():
57+
assert self.process and self.process.stdout
58+
while True:
59+
line = await self.process.stdout.readline()
60+
if not line:
61+
break
62+
m = URL_RE.search(line)
63+
if m and not url_future.done():
64+
url = m.group(0).decode()
65+
url_future.set_result(url)
66+
# Keep draining so the buffer doesn't fill up
67+
68+
consumer = asyncio.create_task(consume())
69+
try:
70+
self.public_url = await asyncio.wait_for(url_future, timeout=timeout)
71+
log.info("tunnel up at %s", self.public_url)
72+
return self.public_url
73+
except asyncio.TimeoutError:
74+
consumer.cancel()
75+
await self.close()
76+
raise RuntimeError(
77+
f"cloudflared did not produce a public URL within {timeout}s — "
78+
"check `cloudflared tunnel --url http://localhost:{self.local_port}` manually"
79+
)
80+
81+
async def close(self) -> None:
82+
if self.process and self.process.returncode is None:
83+
self.process.terminate()
84+
try:
85+
await asyncio.wait_for(self.process.wait(), timeout=5.0)
86+
except asyncio.TimeoutError:
87+
self.process.kill()
88+
await self.process.wait()
89+
self.process = None

0 commit comments

Comments
 (0)