| 1 | import threading |
| 2 | import time |
| 3 | from collections import deque |
| 4 | |
| 5 | from flaredantic import NotifyData, NotifyEvent, notifier |
| 6 | |
| 7 | from helpers.cloudflare_tunnel import CloudflareTunnel |
| 8 | from helpers.microsoft_tunnel import MicrosoftDevTunnel |
| 9 | from helpers.print_style import PrintStyle |
| 10 | from helpers.serveo_tunnel import ServeoTunnelHelper |
| 11 | from helpers.tailscale_tunnel import TailscaleTunnel |
| 12 | from helpers.tunnel_common import event_value |
| 13 | |
| 14 | |
| 15 | SUPPORTED_TUNNEL_PROVIDERS = { |
| 16 | "cloudflared", |
| 17 | "microsoft", |
| 18 | "serveo", |
| 19 | "tailscale", |
| 20 | } |
| 21 | TUNNEL_PROVIDER_ALIASES = { |
| 22 | "cloudflare": "cloudflared", |
| 23 | "cloudflare_tunnel": "cloudflared", |
| 24 | "cloudflare-tunnel": "cloudflared", |
| 25 | "tailscale_funnel": "tailscale", |
| 26 | "tailscale-funnel": "tailscale", |
| 27 | } |
| 28 | |
| 29 | |
| 30 | def normalize_provider(provider): |
| 31 | normalized = (provider or "serveo").strip().lower() |
| 32 | normalized = TUNNEL_PROVIDER_ALIASES.get(normalized, normalized) |
| 33 | if normalized not in SUPPORTED_TUNNEL_PROVIDERS: |
| 34 | supported = ", ".join(sorted(SUPPORTED_TUNNEL_PROVIDERS)) |
| 35 | raise ValueError( |
| 36 | f"Unsupported remote control provider '{provider}'. Choose one of: {supported}." |
| 37 | ) |
| 38 | return normalized |
| 39 | |
| 40 | |
| 41 | # Singleton to manage the tunnel instance |
| 42 | class TunnelManager: |
| 43 | _instance = None |
| 44 | _lock = threading.Lock() |
| 45 | |
| 46 | @classmethod |
| 47 | def get_instance(cls): |
| 48 | with cls._lock: |
| 49 | if cls._instance is None: |
| 50 | cls._instance = cls() |
| 51 | return cls._instance |
| 52 | |
| 53 | def __init__(self): |
| 54 | self.tunnel = None |
| 55 | self.tunnel_url = None |
| 56 | self.is_running = False |
| 57 | self.provider = None |
| 58 | self.notifications = deque(maxlen=50) |
| 59 | self._subscribed = False |
| 60 | |
| 61 | def _on_notify(self, data: NotifyData): |
| 62 | """Handle notifications from flaredantic.""" |
| 63 | self.notifications.append({ |
| 64 | "event": data.event.value, |
| 65 | "message": data.message, |
| 66 | "data": data.data, |
| 67 | }) |
| 68 | |
| 69 | def _ensure_subscribed(self): |
| 70 | """Subscribe to flaredantic notifications if not already.""" |
| 71 | if not self._subscribed: |
| 72 | notifier.subscribe(self._on_notify) |
| 73 | self._subscribed = True |
| 74 | |
| 75 | def get_notifications(self): |
| 76 | """Get and clear pending notifications.""" |
| 77 | notifications = list(self.notifications) |
| 78 | self.notifications.clear() |
| 79 | return notifications |
| 80 | |
| 81 | def get_last_error(self): |
| 82 | """Check for recent error in notifications without clearing.""" |
| 83 | for notification in reversed(list(self.notifications)): |
| 84 | if notification["event"] == NotifyEvent.ERROR.value: |
| 85 | return notification["message"] |
| 86 | return None |
| 87 | |
| 88 | def _append_notification(self, event, message, data=None): |
| 89 | self.notifications.append({ |
| 90 | "event": event_value(event), |
| 91 | "message": message, |
| 92 | "data": data, |
| 93 | }) |
| 94 | |
| 95 | def _create_tunnel(self, port, provider): |
| 96 | if provider == "cloudflared": |
| 97 | return CloudflareTunnel(port, notify=self._append_notification) |
| 98 | if provider == "microsoft": |
| 99 | return MicrosoftDevTunnel(port, notify=self._append_notification) |
| 100 | if provider == "tailscale": |
| 101 | return TailscaleTunnel(port, notify=self._append_notification) |
| 102 | return ServeoTunnelHelper(port, notify=self._append_notification) |
| 103 | |
| 104 | def start_tunnel(self, port=80, provider="serveo"): |
| 105 | """Start a new tunnel or return the existing one's URL.""" |
| 106 | if self.is_running and self.tunnel_url: |
| 107 | return self.tunnel_url |
| 108 | |
| 109 | self._ensure_subscribed() |
| 110 | self.notifications.clear() |
| 111 | try: |
| 112 | self.provider = normalize_provider(provider) |
| 113 | except Exception as e: |
| 114 | error_msg = str(e) |
| 115 | PrintStyle.error(f"Error starting tunnel: {error_msg}") |
| 116 | self._append_notification(NotifyEvent.ERROR, error_msg) |
| 117 | return None |
| 118 | |
| 119 | try: |
| 120 | # Start tunnel in a separate thread to avoid blocking. |
| 121 | def run_tunnel(): |
| 122 | try: |
| 123 | self.tunnel = self._create_tunnel(port, self.provider) |
| 124 | self.tunnel.start() |
| 125 | self.tunnel_url = self.tunnel.tunnel_url |
| 126 | self.is_running = True |
| 127 | except Exception as e: |
| 128 | error_msg = str(e) |
| 129 | PrintStyle.error(f"Error in tunnel thread: {error_msg}") |
| 130 | self._append_notification(NotifyEvent.ERROR, error_msg) |
| 131 | |
| 132 | tunnel_thread = threading.Thread(target=run_tunnel) |
| 133 | tunnel_thread.daemon = True |
| 134 | tunnel_thread.start() |
| 135 | |
| 136 | # No timeout: Microsoft login can legitimately require user interaction. |
| 137 | while True: |
| 138 | if self.tunnel_url: |
| 139 | break |
| 140 | if any(n["event"] == NotifyEvent.ERROR.value for n in self.notifications): |
| 141 | break |
| 142 | if not tunnel_thread.is_alive(): |
| 143 | break |
| 144 | time.sleep(0.1) |
| 145 | |
| 146 | return self.tunnel_url |
| 147 | except Exception as e: |
| 148 | PrintStyle.error(f"Error starting tunnel: {str(e)}") |
| 149 | return None |
| 150 | |
| 151 | def stop_tunnel(self): |
| 152 | """Stop the running tunnel.""" |
| 153 | if self.tunnel and self.is_running: |
| 154 | try: |
| 155 | self.tunnel.stop() |
| 156 | self.is_running = False |
| 157 | self.tunnel_url = None |
| 158 | self.provider = None |
| 159 | return True |
| 160 | except Exception: |
| 161 | return False |
| 162 | return False |
| 163 | |
| 164 | def get_tunnel_url(self): |
| 165 | """Get the current tunnel URL if available.""" |
| 166 | return self.tunnel_url if self.is_running else None |