feat: Microsoft Dev Tunnels

linuztx committed Jan 1, 2026 at 19:26 UTC 5c9c21c2c7dea36c3b2ea31afc738cfd48186e69
4 files changed +274 -61
python/api/tunnel.py
+18 -7
@@ -18,16 +18,19 @@ async def process(input: dict) -> dict | Response:
18 port = runtime.get_web_ui_port()
19 provider = input.get("provider", "serveo") # Default to serveo
20 tunnel_url = tunnel_manager.start_tunnel(port, provider)
21 - if tunnel_url is None:
22 - # Add a little delay and check again - tunnel might be starting
23 - import time
24 - time.sleep(2)
25 - tunnel_url = tunnel_manager.get_tunnel_url()
21 + error = tunnel_manager.get_last_error()
22 + if error:
23 + return {
24 + "success": False,
25 + "tunnel_url": None,
26 + "message": error,
27 + "notifications": tunnel_manager.get_notifications()
28 + }
29
30 return {
31 "success": tunnel_url is not None,
32 "tunnel_url": tunnel_url,
30 - "message": "Tunnel creation in progress" if tunnel_url is None else "Tunnel created successfully"
33 + "notifications": tunnel_manager.get_notifications()
34 }
35
36 elif action == "stop":
@@ -41,9 +44,17 @@ async def process(input: dict) -> dict | Response:
44 "is_running": tunnel_manager.is_running
45 }
46
47 + elif action == "notifications":
48 + return {
49 + "success": True,
50 + "notifications": tunnel_manager.get_notifications(),
51 + "tunnel_url": tunnel_manager.get_tunnel_url(),
52 + "is_running": tunnel_manager.is_running
53 + }
54 +
55 return {
56 "success": False,
46 - "error": "Invalid action. Use 'create', 'stop', or 'get'."
57 + "error": "Invalid action. Use 'create', 'stop', 'get', or 'notifications'."
58 }
59
60 def stop():
python/helpers/tunnel_manager.py
+57 -6
@@ -1,5 +1,11 @@
1 -from flaredantic import FlareTunnel, FlareConfig, ServeoConfig, ServeoTunnel
1 +from flaredantic import (
2 + FlareTunnel, FlareConfig,
3 + ServeoConfig, ServeoTunnel,
4 + MicrosoftTunnel, MicrosoftConfig,
5 + notifier, NotifyData, NotifyEvent
6 +)
7 import threading
8 +from collections import deque
9
10
11 # Singleton to manage the tunnel instance
@@ -19,6 +25,35 @@ class TunnelManager:
25 self.tunnel_url = None
26 self.is_running = False
27 self.provider = None
28 + self.notifications = deque(maxlen=50)
29 + self._subscribed = False
30 +
31 + def _on_notify(self, data: NotifyData):
32 + """Handle notifications from flaredantic"""
33 + self.notifications.append({
34 + "event": data.event.value,
35 + "message": data.message,
36 + "data": data.data
37 + })
38 +
39 + def _ensure_subscribed(self):
40 + """Subscribe to flaredantic notifications if not already"""
41 + if not self._subscribed:
42 + notifier.subscribe(self._on_notify)
43 + self._subscribed = True
44 +
45 + def get_notifications(self):
46 + """Get and clear pending notifications"""
47 + notifications = list(self.notifications)
48 + self.notifications.clear()
49 + return notifications
50 +
51 + def get_last_error(self):
52 + """Check for recent error in notifications without clearing"""
53 + for n in reversed(list(self.notifications)):
54 + if n['event'] == NotifyEvent.ERROR.value:
55 + return n['message']
56 + return None
57
58 def start_tunnel(self, port=80, provider="serveo"):
59 """Start a new tunnel or return the existing one's URL"""
@@ -26,6 +61,8 @@ class TunnelManager:
61 return self.tunnel_url
62
63 self.provider = provider
64 + self._ensure_subscribed()
65 + self.notifications.clear()
66
67 try:
68 # Start tunnel in a separate thread to avoid blocking
@@ -34,6 +71,9 @@ class TunnelManager:
71 if self.provider == "cloudflared":
72 config = FlareConfig(port=port, verbose=True)
73 self.tunnel = FlareTunnel(config)
74 + elif self.provider == "microsoft":
75 + config = MicrosoftConfig(port=port, verbose=True) # type: ignore
76 + self.tunnel = MicrosoftTunnel(config)
77 else: # Default to serveo
78 config = ServeoConfig(port=port) # type: ignore
79 self.tunnel = ServeoTunnel(config)
@@ -42,18 +82,29 @@ class TunnelManager:
82 self.tunnel_url = self.tunnel.tunnel_url
83 self.is_running = True
84 except Exception as e:
45 - print(f"Error in tunnel thread: {str(e)}")
85 + error_msg = str(e)
86 + print(f"Error in tunnel thread: {error_msg}")
87 + self.notifications.append({
88 + "event": NotifyEvent.ERROR.value,
89 + "message": error_msg,
90 + "data": None
91 + })
92
93 tunnel_thread = threading.Thread(target=run_tunnel)
94 tunnel_thread.daemon = True
95 tunnel_thread.start()
96
51 - # Wait for tunnel to start (max 15 seconds instead of 5)
52 - for _ in range(150): # Increased from 50 to 150 iterations
97 + # Wait for tunnel to start (no timeout - user may need time for login)
98 + import time
99 + while True:
100 if self.tunnel_url:
101 break
55 - import time
56 -
102 + # Check if we have errors
103 + if any(n['event'] == NotifyEvent.ERROR.value for n in self.notifications):
104 + break
105 + # Check if thread died without producing URL
106 + if not tunnel_thread.is_alive():
107 + break
108 time.sleep(0.1)
109
110 return self.tunnel_url
webui/components/settings/tunnel/tunnel-section.html
+73
@@ -31,6 +31,7 @@
31 <select id="tunnel-provider" x-model="$store.tunnelStore.provider"
32 :disabled="$store.tunnelStore.isLoading">
33 <option value="cloudflared">Cloudflare</option>
34 + <option value="microsoft">Microsoft Dev Tunnels</option>
35 <option value="serveo">Serveo</option>
36 </select>
37 </div>
@@ -40,6 +41,17 @@
41 <span class="icon material-symbols-outlined spin">progress_activity</span>
42 <span x-text="$store.tunnelStore.loadingText || 'Processing tunnel request...'"></span>
43 </div>
44 + <!-- Microsoft login code display -->
45 + <div class="microsoft-login-box" x-show="$store.tunnelStore.microsoftLoginCode">
46 + <div class="microsoft-login-title">Microsoft Login Required</div>
47 + <div class="microsoft-login-instructions">
48 + Open <a :href="$store.tunnelStore.microsoftLoginUrl" target="_blank" x-text="$store.tunnelStore.microsoftLoginUrl"></a> and enter the code:
49 + </div>
50 + <div class="microsoft-login-code" x-text="$store.tunnelStore.microsoftLoginCode"></div>
51 + <button class="btn btn-copy-code" @click="$store.tunnelStore.copyLoginCode()">
52 + <span class="icon material-symbols-outlined">content_copy</span> Copy Code
53 + </button>
54 + </div>
55
56 <!-- Tunnel content when not loading -->
57 <div x-show="!$store.tunnelStore.isLoading">
@@ -364,6 +376,67 @@
376 background-color: rgba(220, 53, 69, 0.05);
377 color: #dc3545;
378 }
379 +
380 + /* Microsoft Login Box */
381 + .microsoft-login-box {
382 + margin: 1rem 0;
383 + padding: 1.25rem;
384 + background: linear-gradient(135deg, rgba(0, 120, 212, 0.15), rgba(0, 120, 212, 0.05));
385 + border: 1px solid rgba(0, 120, 212, 0.4);
386 + border-radius: 8px;
387 + text-align: center;
388 + }
389 +
390 + .microsoft-login-title {
391 + font-size: 1rem;
392 + font-weight: 600;
393 + color: #0078d4;
394 + margin-bottom: 0.75rem;
395 + }
396 +
397 + .microsoft-login-instructions {
398 + font-size: 0.9rem;
399 + color: var(--text-color-secondary);
400 + margin-bottom: 0.75rem;
401 + line-height: 1.5;
402 + }
403 +
404 + .microsoft-login-instructions a {
405 + color: #0078d4;
406 + text-decoration: underline;
407 + }
408 +
409 + .microsoft-login-code {
410 + font-family: 'Courier New', monospace;
411 + font-size: 1.75rem;
412 + font-weight: bold;
413 + letter-spacing: 0.15em;
414 + color: var(--text-color);
415 + background-color: var(--bg-color-secondary);
416 + padding: 0.75rem 1.5rem;
417 + border-radius: 6px;
418 + display: inline-block;
419 + margin-bottom: 0.75rem;
420 + border: 1px solid var(--border-color);
421 + user-select: all;
422 + }
423 +
424 + .btn-copy-code {
425 + background-color: #0078d4;
426 + color: white;
427 + border: none;
428 + padding: 0.5rem 1rem;
429 + border-radius: 4px;
430 + cursor: pointer;
431 + font-size: 0.875rem;
432 + display: inline-flex;
433 + align-items: center;
434 + gap: 0.25rem;
435 + }
436 +
437 + .btn-copy-code:hover {
438 + background-color: #106ebe;
439 + }
440 </style>
441
442 </body>
webui/components/settings/tunnel/tunnel-store.js
+126 -48
@@ -9,11 +9,112 @@ const model = {
9 loadingText: "",
10 qrCodeInstance: null,
11 provider: "cloudflared",
12 + microsoftLoginCode: "",
13 + microsoftLoginUrl: "",
14 + notificationPollInterval: null,
15 + hasError: false,
16
17 init() {
18 this.checkTunnelStatus();
19 },
20
21 + clearMicrosoftLogin() {
22 + this.microsoftLoginCode = "";
23 + this.microsoftLoginUrl = "";
24 + },
25 +
26 + copyLoginCode() {
27 + if (!this.microsoftLoginCode) return;
28 + navigator.clipboard.writeText(this.microsoftLoginCode).then(() => {
29 + window.toastFrontendInfo("Login code copied to clipboard!", "Clipboard");
30 + }).catch((err) => {
31 + console.error("Failed to copy code: ", err);
32 + window.toastFrontendError("Failed to copy login code", "Clipboard Error");
33 + });
34 + },
35 +
36 + processNotifications(notifications) {
37 + if (!notifications || !Array.isArray(notifications)) return;
38 +
39 + for (const n of notifications) {
40 + switch (n.event) {
41 + case "downloading":
42 + this.loadingText = n.message;
43 + break;
44 + case "download_progress":
45 + if (n.data && n.data.percent !== undefined) {
46 + this.loadingText = `Downloading: ${n.data.percent.toFixed(1)}%`;
47 + } else {
48 + this.loadingText = n.message;
49 + }
50 + break;
51 + case "download_complete":
52 + this.loadingText = n.message;
53 + break;
54 + case "creating_tunnel":
55 + this.loadingText = n.message;
56 + break;
57 + case "info":
58 + // Check for Microsoft login code
59 + if (n.data && n.data.code) {
60 + this.microsoftLoginCode = n.data.code;
61 + this.microsoftLoginUrl = n.data.url || "";
62 + this.loadingText = "Waiting for Microsoft login...";
63 + } else {
64 + this.loadingText = n.message;
65 + }
66 + break;
67 + case "error":
68 + this.hasError = true;
69 + window.toastFrontendError(n.message, "Tunnel Error");
70 + this.stopNotificationPolling();
71 + break;
72 + case "tunnel_url":
73 + if (n.data && n.data.url) {
74 + this.tunnelLink = n.data.url;
75 + this.linkGenerated = true;
76 + }
77 + break;
78 + case "tunnel_stopped":
79 + this.loadingText = n.message;
80 + break;
81 + }
82 + }
83 + },
84 +
85 + startNotificationPolling() {
86 + this.stopNotificationPolling();
87 + this.hasError = false;
88 + this.notificationPollInterval = setInterval(async () => {
89 + try {
90 + const response = await fetchApi("/tunnel_proxy", {
91 + method: "POST",
92 + headers: { "Content-Type": "application/json" },
93 + body: JSON.stringify({ action: "notifications" }),
94 + });
95 + const data = await response.json();
96 + if (data.notifications) {
97 + this.processNotifications(data.notifications);
98 + }
99 + // Check if tunnel is ready
100 + if (data.tunnel_url && data.is_running) {
101 + this.tunnelLink = data.tunnel_url;
102 + this.linkGenerated = true;
103 + this.stopNotificationPolling();
104 + }
105 + } catch (error) {
106 + console.error("Error polling notifications:", error);
107 + }
108 + }, 500);
109 + },
110 +
111 + stopNotificationPolling() {
112 + if (this.notificationPollInterval) {
113 + clearInterval(this.notificationPollInterval);
114 + this.notificationPollInterval = null;
115 + }
116 + },
117 +
118 generateQRCode() {
119 if (!this.tunnelLink) return;
120
@@ -110,6 +211,8 @@ const model = {
211 ) {
212
213 this.isLoading = true;
214 + this.hasError = false;
215 + this.clearMicrosoftLogin();
216 this.loadingText = "Refreshing tunnel...";
217
218 // Change refresh button appearance
@@ -206,7 +309,9 @@ const model = {
309 }
310
311 this.isLoading = true;
209 - this.loadingText = "Creating tunnel...";
312 + this.hasError = false;
313 + this.clearMicrosoftLogin();
314 + this.loadingText = "Starting tunnel...";
315
316 // Change create button appearance
317 const createButton = document.querySelector("#tunnel-settings-section .tunnel-actions .btn-ok");
@@ -217,6 +322,9 @@ const model = {
322 createButton.classList.add("creating");
323 }
324
325 + // Start polling for notifications
326 + this.startNotificationPolling();
327 +
328 try {
329 // Call the backend API to create a tunnel
330 const response = await fetchApi("/tunnel_proxy", {
@@ -227,18 +335,32 @@ const model = {
335 body: JSON.stringify({
336 action: "create",
337 provider: this.provider,
230 - // port: window.location.port || (window.location.protocol === 'https:' ? 443 : 80)
338 }),
339 });
340
341 const data = await response.json();
342
343 + // Process any notifications from response
344 + if (data.notifications) {
345 + this.processNotifications(data.notifications);
346 + }
347 +
348 + // Check for error
349 + if (!data.success && data.message) {
350 + this.hasError = true;
351 + window.toastFrontendError(data.message, "Tunnel Error");
352 + console.error("Tunnel creation failed:", data);
353 + this.stopNotificationPolling();
354 + return;
355 + }
356 +
357 if (data.success && data.tunnel_url) {
358 // Store the tunnel URL in localStorage for persistence
359 localStorage.setItem("agent_zero_tunnel_url", data.tunnel_url);
360
361 this.tunnelLink = data.tunnel_url;
362 this.linkGenerated = true;
363 + this.stopNotificationPolling();
364
365 // Generate QR code for the tunnel URL
366 Sleep.Skip().then(() => this.generateQRCode());
@@ -248,52 +370,6 @@ const model = {
370 "Tunnel created successfully",
371 "Tunnel Status"
372 );
251 - } else {
252 - // The tunnel might still be starting up, check again after a delay
253 - this.loadingText = "Tunnel creation taking longer than expected...";
254 -
255 - // Wait for 5 seconds and check if the tunnel is running
256 - await new Promise((resolve) => setTimeout(resolve, 5000));
257 -
258 - // Check if tunnel is running now
259 - try {
260 - const statusResponse = await fetchApi("/tunnel_proxy", {
261 - method: "POST",
262 - headers: {
263 - "Content-Type": "application/json",
264 - },
265 - body: JSON.stringify({ action: "get" }),
266 - });
267 -
268 - const statusData = await statusResponse.json();
269 -
270 - if (statusData.success && statusData.tunnel_url) {
271 - // Tunnel is now running, we can update the UI
272 - localStorage.setItem(
273 - "agent_zero_tunnel_url",
274 - statusData.tunnel_url
275 - );
276 - this.tunnelLink = statusData.tunnel_url;
277 - this.linkGenerated = true;
278 -
279 - // Generate QR code for the tunnel URL
280 - Sleep.Skip().then(() => this.generateQRCode());
281 -
282 - window.toastFrontendInfo(
283 - "Tunnel created successfully",
284 - "Tunnel Status"
285 - );
286 - return;
287 - }
288 - } catch (statusError) {
289 - console.error("Error checking tunnel status:", statusError);
290 - }
291 -
292 - // If we get here, the tunnel really failed to start
293 - const errorMessage =
294 - data.message || "Failed to create tunnel. Please try again.";
295 - window.toastFrontendError(errorMessage, "Tunnel Error");
296 - console.error("Tunnel creation failed:", data);
373 }
374 } catch (error) {
375 window.toastFrontendError("Error creating tunnel", "Tunnel Error");
@@ -301,6 +377,8 @@ const model = {
377 } finally {
378 this.isLoading = false;
379 this.loadingText = "";
380 + this.stopNotificationPolling();
381 + this.clearMicrosoftLogin();
382
383 // Reset create button if it's still in the DOM
384 const createButton = document.querySelector("#tunnel-settings-section .tunnel-actions .btn-ok");