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;