2025-10-04 12:22:50 +05:30
|
|
|
"""Utility for getting the cloudflared tunnel URL at runtime."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import re
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TunnelURLProvider:
|
2026-01-29 14:06:08 +05:30
|
|
|
"""Provider for getting tunnel URLs from cloudflared service."""
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-29 14:06:08 +05:30
|
|
|
async def get_tunnel_urls(cls) -> tuple[str, str]:
|
2025-10-04 12:22:50 +05:30
|
|
|
"""
|
2026-01-29 14:06:08 +05:30
|
|
|
Get the tunnel URLs for external access.
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
Returns:
|
2026-01-29 14:06:08 +05:30
|
|
|
tuple[str, str]: (https_url, wss_url) - Both URLs include full protocol
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ValueError: If no tunnel URL can be determined
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Try to get URL from cloudflared metrics
|
2026-01-29 14:06:08 +05:30
|
|
|
urls = await cls._get_cloudflared_urls()
|
|
|
|
|
if urls:
|
|
|
|
|
return urls
|
2025-10-04 12:22:50 +05:30
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to get tunnel URL from cloudflared: {e}")
|
|
|
|
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"No tunnel URL available. Please set BACKEND_API_ENDPOINT environment "
|
|
|
|
|
"variable or ensure cloudflared service is running."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-29 14:06:08 +05:30
|
|
|
async def _get_cloudflared_urls(cls) -> Optional[tuple[str, str]]:
|
2025-10-04 12:22:50 +05:30
|
|
|
"""
|
2026-01-29 14:06:08 +05:30
|
|
|
Query cloudflared metrics endpoint to get the tunnel URLs.
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
Returns:
|
2026-01-29 14:06:08 +05:30
|
|
|
Optional[tuple[str, str]]: (https_url, wss_url) with full protocols, or None if not found
|
2025-10-04 12:22:50 +05:30
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Try to connect to cloudflared metrics endpoint
|
|
|
|
|
# The service name in docker-compose is 'cloudflared'
|
|
|
|
|
metrics_url = "http://cloudflared:2000/metrics"
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
async with session.get(
|
|
|
|
|
metrics_url, timeout=aiohttp.ClientTimeout(total=5)
|
|
|
|
|
) as response:
|
|
|
|
|
if response.status != 200:
|
|
|
|
|
logger.warning(
|
|
|
|
|
f"Cloudflared metrics returned status {response.status}"
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
text = await response.text()
|
|
|
|
|
|
|
|
|
|
# Look for the tunnel URL in metrics
|
|
|
|
|
# Cloudflared exposes this in the userHostname metric
|
|
|
|
|
match = re.search(r'userHostname="([^"]+)"', text)
|
|
|
|
|
if match:
|
|
|
|
|
hostname = match.group(1)
|
|
|
|
|
# Remove https:// or wss:// if present
|
|
|
|
|
hostname = hostname.replace("https://", "").replace(
|
|
|
|
|
"wss://", ""
|
|
|
|
|
)
|
2026-01-29 14:06:08 +05:30
|
|
|
return "https://" + hostname, "wss://" + hostname
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
# Alternative: Look for trycloudflare.com domain
|
|
|
|
|
match = re.search(r"([a-z0-9-]+\.trycloudflare\.com)", text)
|
|
|
|
|
if match:
|
2026-01-29 14:06:08 +05:30
|
|
|
hostname = match.group(1)
|
|
|
|
|
hostname = hostname.replace("https://", "").replace(
|
|
|
|
|
"wss://", ""
|
|
|
|
|
)
|
|
|
|
|
return f"https://{hostname}", f"wss://{hostname}"
|
2025-10-04 12:22:50 +05:30
|
|
|
|
|
|
|
|
logger.warning("Could not find tunnel URL in cloudflared metrics")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
logger.warning("Timeout connecting to cloudflared metrics endpoint")
|
|
|
|
|
return None
|
|
|
|
|
except aiohttp.ClientError as e:
|
|
|
|
|
logger.warning(f"Error connecting to cloudflared: {e}")
|
|
|
|
|
return None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Unexpected error getting cloudflared URL: {e}")
|
|
|
|
|
return None
|