| 1 | import os |
| 2 | import platform |
| 3 | import requests |
| 4 | import subprocess |
| 5 | import threading |
| 6 | from helpers import files |
| 7 | from helpers.print_style import PrintStyle |
| 8 | |
| 9 | class CloudflareTunnel: |
| 10 | def __init__(self, port: int): |
| 11 | self.port = port |
| 12 | self.bin_dir = "tmp" # Relative path |
| 13 | self.cloudflared_path = None |
| 14 | self.tunnel_process = None |
| 15 | self.tunnel_url = None |
| 16 | self._stop_event = threading.Event() |
| 17 | |
| 18 | def download_cloudflared(self): |
| 19 | """Downloads the appropriate cloudflared binary for the current system""" |
| 20 | # Create bin directory if it doesn't exist using files helper |
| 21 | os.makedirs(files.get_abs_path(self.bin_dir), exist_ok=True) |
| 22 | |
| 23 | # Determine OS and architecture |
| 24 | system = platform.system().lower() |
| 25 | arch = platform.machine().lower() |
| 26 | |
| 27 | # Define executable name |
| 28 | executable_name = "cloudflared.exe" if system == "windows" else "cloudflared" |
| 29 | install_path = files.get_abs_path(self.bin_dir, executable_name) |
| 30 | |
| 31 | # Return if already exists |
| 32 | if files.exists(self.bin_dir, executable_name): |
| 33 | self.cloudflared_path = install_path |
| 34 | return install_path |
| 35 | |
| 36 | # Map platform/arch to download URLs |
| 37 | base_url = "https://github.com/cloudflare/cloudflared/releases/latest/download/" |
| 38 | |
| 39 | if system == "darwin": # macOS |
| 40 | # Download and extract .tgz for macOS |
| 41 | download_file = "cloudflared-darwin-amd64.tgz" if arch == "x86_64" else "cloudflared-darwin-arm64.tgz" |
| 42 | download_url = f"{base_url}{download_file}" |
| 43 | download_path = files.get_abs_path(self.bin_dir, download_file) |
| 44 | |
| 45 | PrintStyle().print(f"\nDownloading cloudflared from: {download_url}") |
| 46 | response = requests.get(download_url, stream=True) |
| 47 | if response.status_code != 200: |
| 48 | raise RuntimeError(f"Failed to download cloudflared: {response.status_code}") |
| 49 | |
| 50 | # Save the .tgz file |
| 51 | with open(download_path, "wb") as f: |
| 52 | for chunk in response.iter_content(chunk_size=8192): |
| 53 | f.write(chunk) |
| 54 | |
| 55 | # Extract cloudflared binary from .tgz |
| 56 | import tarfile |
| 57 | with tarfile.open(download_path, "r:gz") as tar: |
| 58 | tar.extract("cloudflared", files.get_abs_path(self.bin_dir)) |
| 59 | |
| 60 | # Cleanup .tgz file |
| 61 | os.remove(download_path) |
| 62 | |
| 63 | else: # Linux and Windows |
| 64 | if system == "linux": |
| 65 | if arch in ["x86_64", "amd64"]: |
| 66 | download_file = "cloudflared-linux-amd64" |
| 67 | elif arch == "arm64" or arch == "aarch64": |
| 68 | download_file = "cloudflared-linux-arm64" |
| 69 | elif arch == "arm": |
| 70 | download_file = "cloudflared-linux-arm" |
| 71 | else: |
| 72 | download_file = "cloudflared-linux-386" |
| 73 | elif system == "windows": |
| 74 | download_file = "cloudflared-windows-amd64.exe" |
| 75 | else: |
| 76 | raise RuntimeError(f"Unsupported platform: {system} {arch}") |
| 77 | |
| 78 | download_url = f"{base_url}{download_file}" |
| 79 | download_path = files.get_abs_path(self.bin_dir, download_file) |
| 80 | |
| 81 | PrintStyle().print(f"\nDownloading cloudflared from: {download_url}") |
| 82 | response = requests.get(download_url, stream=True) |
| 83 | if response.status_code != 200: |
| 84 | raise RuntimeError(f"Failed to download cloudflared: {response.status_code}") |
| 85 | |
| 86 | with open(download_path, "wb") as f: |
| 87 | for chunk in response.iter_content(chunk_size=8192): |
| 88 | f.write(chunk) |
| 89 | |
| 90 | |
| 91 | # Rename and set permissions |
| 92 | if os.path.exists(install_path): |
| 93 | os.remove(install_path) |
| 94 | os.rename(download_path, install_path) |
| 95 | |
| 96 | # Set executable permissions |
| 97 | if system != "windows": |
| 98 | os.chmod(install_path, 0o755) |
| 99 | |
| 100 | self.cloudflared_path = install_path |
| 101 | return install_path |
| 102 | |
| 103 | def _extract_tunnel_url(self, process): |
| 104 | """Extracts the tunnel URL from cloudflared output""" |
| 105 | while not self._stop_event.is_set(): |
| 106 | line = process.stdout.readline() |
| 107 | if not line: |
| 108 | break |
| 109 | |
| 110 | if isinstance(line, bytes): |
| 111 | line = line.decode('utf-8') |
| 112 | |
| 113 | if "trycloudflare.com" in line and "https://" in line: |
| 114 | start = line.find("https://") |
| 115 | end = line.find("trycloudflare.com") + len("trycloudflare.com") |
| 116 | self.tunnel_url = line[start:end].strip() |
| 117 | PrintStyle().print("\n=== Cloudflare Tunnel URL ===") |
| 118 | PrintStyle().print(f"URL: {self.tunnel_url}") |
| 119 | PrintStyle().print("============================\n") |
| 120 | return |
| 121 | |
| 122 | def start(self): |
| 123 | """Starts Cloudflare Tunnel""" |
| 124 | if not self.cloudflared_path: |
| 125 | self.download_cloudflared() |
| 126 | |
| 127 | PrintStyle().print("\nStarting Cloudflare Tunnel...") |
| 128 | # Start tunnel process |
| 129 | self.tunnel_process = subprocess.Popen( |
| 130 | [ |
| 131 | str(self.cloudflared_path), |
| 132 | "tunnel", |
| 133 | "--url", |
| 134 | f"http://localhost:{self.port}" |
| 135 | ], |
| 136 | stdout=subprocess.PIPE, |
| 137 | stderr=subprocess.STDOUT, |
| 138 | bufsize=1, |
| 139 | universal_newlines=True |
| 140 | ) |
| 141 | |
| 142 | # Extract tunnel URL in separate thread |
| 143 | threading.Thread( |
| 144 | target=self._extract_tunnel_url, |
| 145 | args=(self.tunnel_process,), |
| 146 | daemon=True |
| 147 | ).start() |
| 148 | |
| 149 | def stop(self): |
| 150 | """Stops Cloudflare Tunnel""" |
| 151 | self._stop_event.set() |
| 152 | if self.tunnel_process: |
| 153 | PrintStyle().print("\nStopping Cloudflare Tunnel...") |
| 154 | self.tunnel_process.terminate() |
| 155 | self.tunnel_process.wait() |
| 156 | self.tunnel_process = None |
| 157 | self.tunnel_url = None |