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;