Agent Zero AI framework
Harden Tailscale Funnel remote control
Start Tailscale Remote Control through Funnel on HTTPS port 443, verify the prepared client supports the funnel command, and give the Funnel startup path a longer approval window. Forward Tailscale approval/login URLs through the shared tunnel notification stream so the UI renders an actionable browser link like Microsoft Dev Tunnels, with clearer basic-user copy around approval and public Funnel URLs. Keep ngrok removed from the Remote Control surface and extend regression coverage for Funnel command wiring, unsupported clients, and approval-link handling.
Alessandro committed
Jun 1, 2026 at 02:11 UTC
bb6004c77fb379c93a4626be579305e7f777be61
5 files changed
+202
-7
helpers/cli_tunnel.py
+7
@@ -148,6 +148,7 @@ class CliTunnelHelper(TunnelHelper):
148
notify=None,
149
binary_resolver=None,
150
preflight=None,
151
+ output_handler=None,
152
):
153
super().__init__(port, notify=notify)
154
self.label = label
@@ -162,6 +163,7 @@ class CliTunnelHelper(TunnelHelper):
163
self.binary_resolver = binary_resolver
164
self.preflight = preflight
165
self.command_prefix = []
166
+ self.output_handler = output_handler
167
168
def _extract_url(self, line):
169
match = self.url_pattern.search(line)
@@ -216,6 +218,11 @@ class CliTunnelHelper(TunnelHelper):
218
cleaned_line = line.strip()
219
if cleaned_line:
220
recent_output.append(cleaned_line)
221
+ if callable(self.output_handler):
222
+ try:
223
+ self.output_handler(cleaned_line, self._notify)
224
+ except Exception:
225
+ pass
226
url = self._extract_url(cleaned_line)
227
if url:
228
self.tunnel_url = url
helpers/tailscale_tunnel.py
+64
-3
@@ -21,6 +21,8 @@ TAILSCALE_URL_RE = re.compile(r"https://[a-zA-Z0-9.-]+\.ts\.net[^\s\"']*")
21
TAILSCALE_LOGIN_URL_RE = re.compile(r"https://login\.tailscale\.com/[^\s\"']+")
22
TAILSCALE_STABLE_PACKAGES_URL = "https://pkgs.tailscale.com/stable/?v=latest"
23
TAILSCALE_UP_TIMEOUT = 180
24
+TAILSCALE_FUNNEL_TIMEOUT = 300
25
+TAILSCALE_FUNNEL_HTTPS_PORT = "443"
26
TAILSCALE_DAEMON_START_TIMEOUT = 12
27
TAILSCALE_RUNTIME_DIR = Path(files.get_abs_path("tmp", "tailscale"))
28
TAILSCALE_STATE_DIR = Path(files.get_abs_path("usr", "tailscale"))
@@ -102,6 +104,16 @@ def tailscale_status(binary_path, socket_path=None):
104
)
105
106
107
+def tailscale_funnel_help(binary_path, socket_path=None):
108
+ return subprocess.run(
109
+ tailscale_command(binary_path, ["funnel", "--help"], socket_path),
110
+ check=False,
111
+ text=True,
112
+ capture_output=True,
113
+ timeout=12,
114
+ )
115
+
116
+
117
def compact_output(lines):
118
return " ".join(line.strip() for line in lines if line and line.strip())
119
@@ -355,6 +367,21 @@ def run_tailscale_up(
367
)
368
369
370
+def ensure_tailscale_funnel_command(binary_path, socket_path=None):
371
+ completed = tailscale_funnel_help(binary_path, socket_path=socket_path)
372
+ output = compact_output([completed.stderr, completed.stdout])
373
+ if completed.returncode == 0 and "tailscale funnel" in output.lower():
374
+ return
375
+
376
+ details = f" Details: {output}" if output else ""
377
+ raise RuntimeError(
378
+ "Agent Zero prepared Tailscale, but this Tailscale binary does not "
379
+ "support `tailscale funnel`. Tailscale Remote Control needs Tailscale "
380
+ "v1.38.3 or newer with Funnel support enabled for your tailnet."
381
+ f"{details}"
382
+ )
383
+
384
+
385
def ensure_tailscale_ready(binary_path, notify=None):
386
socket_path = None
387
completed = tailscale_status(binary_path)
@@ -381,7 +408,8 @@ def ensure_tailscale_ready(binary_path, notify=None):
408
try:
409
payload = json.loads(completed.stdout or "{}")
410
except json.JSONDecodeError:
384
- return
411
+ ensure_tailscale_funnel_command(binary_path, socket_path=socket_path)
412
+ return {"command_prefix": tailscale_socket_args(socket_path)}
413
414
backend_state = str(payload.get("BackendState") or "").lower()
415
if backend_state and backend_state != "running":
@@ -390,6 +418,7 @@ def ensure_tailscale_ready(binary_path, notify=None):
418
try:
419
payload = json.loads(completed.stdout or "{}")
420
except json.JSONDecodeError:
421
+ ensure_tailscale_funnel_command(binary_path, socket_path=socket_path)
422
return {"command_prefix": tailscale_socket_args(socket_path)}
423
backend_state = str(payload.get("BackendState") or "").lower()
424
if backend_state and backend_state != "running":
@@ -400,31 +429,63 @@ def ensure_tailscale_ready(binary_path, notify=None):
429
"approval, then try again."
430
)
431
432
+ ensure_tailscale_funnel_command(binary_path, socket_path=socket_path)
433
return {"command_prefix": tailscale_socket_args(socket_path)}
434
435
436
class TailscaleTunnel(CliTunnelHelper):
437
def __init__(self, port, notify=None):
438
target = f"http://127.0.0.1:{port}"
439
+ self._announced_login_urls = set()
440
super().__init__(
441
label="Tailscale Funnel",
442
binary="tailscale",
443
port=port,
413
- command=["tailscale", "funnel", "--yes", target],
444
+ command=[
445
+ "tailscale",
446
+ "funnel",
447
+ "--yes",
448
+ f"--https={TAILSCALE_FUNNEL_HTTPS_PORT}",
449
+ target,
450
+ ],
451
url_pattern=TAILSCALE_URL_RE,
452
missing_binary_message=(
453
"Tailscale could not be prepared in this environment. Install "
454
"Tailscale, make sure the `tailscaled` service is available to "
455
"this container, enable Funnel for the tailnet, then try again."
456
),
420
- shutdown_command=["tailscale", "funnel", "--yes", target, "off"],
457
+ shutdown_command=[
458
+ "tailscale",
459
+ "funnel",
460
+ "--yes",
461
+ f"--https={TAILSCALE_FUNNEL_HTTPS_PORT}",
462
+ target,
463
+ "off",
464
+ ],
465
+ timeout=TAILSCALE_FUNNEL_TIMEOUT,
466
binary_resolver=lambda notify_callback: install_tailscale(
467
notify=notify_callback
468
),
469
preflight=ensure_tailscale_ready,
470
+ output_handler=self._handle_tailscale_output,
471
notify=notify,
472
)
473
474
+ def _handle_tailscale_output(self, line, notify=None):
475
+ login_match = TAILSCALE_LOGIN_URL_RE.search(line)
476
+ if not login_match:
477
+ return
478
+ login_url = login_match.group(0).rstrip(".,)")
479
+ if login_url in self._announced_login_urls:
480
+ return
481
+ self._announced_login_urls.add(login_url)
482
+ notify_info(
483
+ notify,
484
+ "Open the Tailscale approval link to finish sign-in or enable Funnel. "
485
+ "Agent Zero will continue when Tailscale reports the public URL.",
486
+ {"provider": "tailscale", "url": login_url},
487
+ )
488
+
489
def stop(self):
490
try:
491
return super().stop()
tests/test_tunnel_remote_link.py
+128
-1
@@ -146,7 +146,8 @@ def test_remote_link_provider_options_match_supported_remote_link_providers():
146
assert '<option value="serveo">Serveo</option>' in html
147
assert '<option value="cloudflared">Cloudflare</option>' not in html
148
assert "Cloudflare Tunnel is the quickest shareable URL" in html
149
- assert "Agent Zero will start its sign-in flow" in html
149
+ assert "Tailscale Funnel creates a public HTTPS URL" in html
150
+ assert "approve sign-in or Funnel access" in html
151
152
153
def test_tunnel_provider_normalization_preserves_aliases(tunnel_manager_module):
@@ -162,6 +163,7 @@ def test_tunnel_provider_normalization_preserves_aliases(tunnel_manager_module):
163
164
def test_tailscale_cli_commands_are_wired(tunnel_manager_module):
165
manager = tunnel_manager_module.TunnelManager()
166
+ modules = remote_link_modules()
167
168
tailscale = manager._create_tunnel(50001, "tailscale")
169
@@ -169,15 +171,18 @@ def test_tailscale_cli_commands_are_wired(tunnel_manager_module):
171
"tailscale",
172
"funnel",
173
"--yes",
174
+ "--https=443",
175
"http://127.0.0.1:50001",
176
]
177
assert tailscale.shutdown_command == [
178
"tailscale",
179
"funnel",
180
"--yes",
181
+ "--https=443",
182
"http://127.0.0.1:50001",
183
"off",
184
]
185
+ assert tailscale.timeout == modules.tailscale.TAILSCALE_FUNNEL_TIMEOUT
186
187
188
def test_remote_link_providers_have_dedicated_helper_modules():
@@ -523,6 +528,15 @@ def test_tailscale_preflight_starts_managed_daemon_then_runs_up_with_socket(
528
return next(status_results)
529
530
monkeypatch.setattr(modules.tailscale, "tailscale_status", fake_status)
531
+ monkeypatch.setattr(
532
+ modules.tailscale,
533
+ "tailscale_funnel_help",
534
+ lambda binary_path, socket_path=None: types.SimpleNamespace(
535
+ returncode=0,
536
+ stdout="USAGE\n tailscale funnel <target>",
537
+ stderr="",
538
+ ),
539
+ )
540
popen_commands = []
541
542
class FakeProcess:
@@ -651,6 +665,15 @@ def test_tailscale_preflight_runs_up_then_accepts_running_status(
665
"tailscale_status",
666
lambda binary_path, socket_path=None: next(status_results),
667
)
668
+ monkeypatch.setattr(
669
+ modules.tailscale,
670
+ "tailscale_funnel_help",
671
+ lambda binary_path, socket_path=None: types.SimpleNamespace(
672
+ returncode=0,
673
+ stdout="USAGE\n tailscale funnel <target>",
674
+ stderr="",
675
+ ),
676
+ )
677
678
class FakeProcess:
679
stdout = iter(["Success.\n"])
@@ -681,6 +704,110 @@ def test_tailscale_preflight_runs_up_then_accepts_running_status(
704
}
705
706
707
+def test_tailscale_preflight_rejects_clients_without_funnel(
708
+ tunnel_manager_module,
709
+ monkeypatch,
710
+):
711
+ modules = remote_link_modules()
712
+ monkeypatch.setattr(
713
+ modules.tailscale,
714
+ "tailscale_status",
715
+ lambda binary_path, socket_path=None: types.SimpleNamespace(
716
+ returncode=0,
717
+ stdout='{"BackendState": "Running"}',
718
+ stderr="",
719
+ ),
720
+ )
721
+ monkeypatch.setattr(
722
+ modules.tailscale,
723
+ "tailscale_funnel_help",
724
+ lambda binary_path, socket_path=None: types.SimpleNamespace(
725
+ returncode=1,
726
+ stdout="",
727
+ stderr="unknown command: funnel",
728
+ ),
729
+ )
730
+
731
+ with pytest.raises(RuntimeError, match="does not support `tailscale funnel`"):
732
+ modules.tailscale.ensure_tailscale_ready("/tmp/tailscale")
733
+
734
+
735
+def test_tailscale_funnel_command_surfaces_approval_url(
736
+ tunnel_manager_module,
737
+ monkeypatch,
738
+):
739
+ modules = remote_link_modules()
740
+ popen_commands = []
741
+ notifications = []
742
+ monkeypatch.setattr(
743
+ modules.tailscale,
744
+ "install_tailscale",
745
+ lambda notify=None: "/tmp/tailscale",
746
+ )
747
+ monkeypatch.setattr(
748
+ modules.tailscale,
749
+ "ensure_tailscale_ready",
750
+ lambda binary_path, notify=None: {
751
+ "command_prefix": ["--socket", "/tmp/tailscaled.sock"]
752
+ },
753
+ )
754
+
755
+ class FakeProcess:
756
+ def __init__(self, command, **kwargs):
757
+ popen_commands.append(command)
758
+ self.stdout = iter([
759
+ "Visit https://login.tailscale.com/a/funnel-approval to enable Funnel\n",
760
+ "Available on the internet:\n",
761
+ "https://agent-zero.example.ts.net\n",
762
+ ])
763
+
764
+ def poll(self):
765
+ return None
766
+
767
+ def wait(self, timeout=None):
768
+ return 0
769
+
770
+ monkeypatch.setattr(modules.cli.subprocess, "Popen", FakeProcess)
771
+
772
+ tunnel = modules.tailscale.TailscaleTunnel(
773
+ 50001,
774
+ notify=lambda event, message, data=None: notifications.append({
775
+ "event": event.value,
776
+ "message": message,
777
+ "data": data,
778
+ }),
779
+ )
780
+
781
+ assert tunnel.start() == "https://agent-zero.example.ts.net"
782
+ assert popen_commands == [
783
+ [
784
+ "/tmp/tailscale",
785
+ "--socket",
786
+ "/tmp/tailscaled.sock",
787
+ "funnel",
788
+ "--yes",
789
+ "--https=443",
790
+ "http://127.0.0.1:50001",
791
+ ]
792
+ ]
793
+ assert {
794
+ "event": "info",
795
+ "message": (
796
+ "Open the Tailscale approval link to finish sign-in or enable Funnel. "
797
+ "Agent Zero will continue when Tailscale reports the public URL."
798
+ ),
799
+ "data": {
800
+ "provider": "tailscale",
801
+ "url": "https://login.tailscale.com/a/funnel-approval",
802
+ },
803
+ } in notifications
804
+ assert notifications[-1] == {
805
+ "event": "tunnel_url",
806
+ "message": "Tailscale Funnel URL is ready",
807
+ "data": {"url": "https://agent-zero.example.ts.net"},
808
+ }
809
+
810
+
811
def test_cli_tunnel_preflight_prefix_is_used_for_start_and_shutdown(
812
tunnel_manager_module,
813
monkeypatch,
webui/components/settings/tunnel/tunnel-section.html
+1
-1
@@ -36,7 +36,7 @@
36
<div class="field-label">
37
<div class="field-title">Link provider</div>
38
<div class="field-description">
39
- Cloudflare Tunnel is the quickest shareable URL. Tailscale is private to your tailnet and Agent Zero will start its sign-in flow when needed. Microsoft Dev Tunnels may ask you to approve a GitHub device login.
39
+ Cloudflare Tunnel is the quickest shareable URL. Tailscale Funnel creates a public HTTPS URL after this container joins your tailnet and may ask you to approve sign-in or Funnel access. Microsoft Dev Tunnels may ask you to approve a GitHub device login.
40
</div>
41
</div>
42
<div class="field-control">
webui/components/settings/tunnel/tunnel-store.js
+2
-2
@@ -47,7 +47,7 @@ const model = {
47
48
get loginActionCopy() {
49
if (this.loginProvider === "tailscale") {
50
- return "Open the Tailscale login link and approve this container. Agent Zero will continue when Tailscale finishes setup.";
50
+ return "Open the Tailscale link to approve this container or enable Funnel. Agent Zero will continue when Tailscale reports the public URL.";
51
}
52
return "Approve the tunnel request, then Agent Zero will finish enabling Remote Control.";
53
},
@@ -103,7 +103,7 @@ const model = {
103
this.microsoftLoginCode = n.data.code || "";
104
this.microsoftLoginUrl = n.data.url || "";
105
this.loadingText = this.loginProvider === "tailscale"
106
- ? "Waiting for Tailscale login..."
106
+ ? "Waiting for Tailscale approval..."
107
: "Waiting for Microsoft login...";
108
} else {
109
this.loadingText = n.message;