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");