main
py 166 lines 5.57 KB
Raw
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