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;