main
py 884 lines 26.2 KB
Raw
1 import io
2 import enum
3 import importlib
4 import os
5 import sys
6 import tarfile
7 import types
8 import zipfile
9 from pathlib import Path
10
11 import pytest
12
13
14 PROJECT_ROOT = Path(__file__).resolve().parents[1]
15 if str(PROJECT_ROOT) not in sys.path:
16 sys.path.insert(0, str(PROJECT_ROOT))
17
18
19 class FakeNotifyEvent(enum.Enum):
20 DOWNLOADING = "downloading"
21 DOWNLOAD_PROGRESS = "download_progress"
22 DOWNLOAD_COMPLETE = "download_complete"
23 CREATING_TUNNEL = "creating_tunnel"
24 TUNNEL_URL = "tunnel_url"
25 TUNNEL_STOPPED = "tunnel_stopped"
26 ERROR = "error"
27 INFO = "info"
28
29
30 class FakeConfig:
31 def __init__(self, **kwargs):
32 self.kwargs = kwargs
33 for key, value in kwargs.items():
34 setattr(self, key, value)
35
36
37 class FakeTunnel:
38 def __init__(self, config):
39 self.config = config
40 self.tunnel_url = ""
41 self.stopped = False
42 self.notifications = []
43
44 def start(self):
45 self.tunnel_url = "https://example.test"
46 return self.tunnel_url
47
48 def stop(self):
49 self.stopped = True
50 return True
51
52 def notify(self, event, message, data=None):
53 self.notifications.append({
54 "event": event.value if hasattr(event, "value") else event,
55 "message": message,
56 "data": data,
57 })
58
59
60 class FakeNotifier:
61 def subscribe(self, callback):
62 self.callback = callback
63
64
65 def write_tar_archive(path, members):
66 path.parent.mkdir(parents=True, exist_ok=True)
67 with tarfile.open(path, "w:gz") as archive:
68 for name, content in members.items():
69 payload = content.encode("utf-8")
70 info = tarfile.TarInfo(name)
71 info.size = len(payload)
72 info.mode = 0o755
73 archive.addfile(info, io.BytesIO(payload))
74
75
76 def write_zip_archive(path, members):
77 path.parent.mkdir(parents=True, exist_ok=True)
78 with zipfile.ZipFile(path, "w") as archive:
79 for name, content in members.items():
80 archive.writestr(name, content)
81
82
83 HELPER_MODULES = [
84 "helpers.cli_tunnel",
85 "helpers.cloudflare_tunnel",
86 "helpers.microsoft_tunnel",
87 "helpers.serveo_tunnel",
88 "helpers.tailscale_tunnel",
89 "helpers.tunnel_common",
90 "helpers.tunnel_manager",
91 ]
92
93
94 def remote_link_modules():
95 return types.SimpleNamespace(
96 cli=importlib.import_module("helpers.cli_tunnel"),
97 microsoft=importlib.import_module("helpers.microsoft_tunnel"),
98 tailscale=importlib.import_module("helpers.tailscale_tunnel"),
99 )
100
101
102 @pytest.fixture()
103 def tunnel_manager_module(monkeypatch):
104 fake_flaredantic = types.SimpleNamespace(
105 FlareConfig=FakeConfig,
106 FlareTunnel=FakeTunnel,
107 MicrosoftConfig=FakeConfig,
108 MicrosoftTunnel=FakeTunnel,
109 NotifyData=object,
110 NotifyEvent=FakeNotifyEvent,
111 ServeoConfig=FakeConfig,
112 ServeoTunnel=FakeTunnel,
113 notifier=FakeNotifier(),
114 )
115 monkeypatch.setitem(sys.modules, "flaredantic", fake_flaredantic)
116 helpers_package = sys.modules.get("helpers")
117 for module_name in HELPER_MODULES:
118 sys.modules.pop(module_name, None)
119 if helpers_package and hasattr(helpers_package, module_name.rsplit(".", 1)[-1]):
120 delattr(helpers_package, module_name.rsplit(".", 1)[-1])
121 module = importlib.import_module("helpers.tunnel_manager")
122 yield module
123 for module_name in HELPER_MODULES:
124 sys.modules.pop(module_name, None)
125 if helpers_package and hasattr(helpers_package, module_name.rsplit(".", 1)[-1]):
126 delattr(helpers_package, module_name.rsplit(".", 1)[-1])
127
128
129 def test_remote_link_provider_options_match_supported_remote_link_providers():
130 html = (
131 PROJECT_ROOT / "webui/components/settings/tunnel/tunnel-section.html"
132 ).read_text(encoding="utf-8")
133
134 assert html.count("<option value=") == 4
135 assert "Remote Control" in html
136 assert "Remote " + "Link" not in html
137 assert "loginActionVisible" in html
138 assert "loginActionTitle" in html
139 assert (
140 'class="microsoft-login-box" x-show="$store.tunnelStore.loginActionVisible"'
141 in html
142 )
143 assert '<option value="cloudflared">Cloudflare Tunnel</option>' in html
144 assert '<option value="tailscale">Tailscale</option>' in html
145 assert '<option value="microsoft">Microsoft Dev Tunnels</option>' in html
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 "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):
154 manager = tunnel_manager_module
155
156 assert manager.normalize_provider("cloudflare") == "cloudflared"
157 assert manager.normalize_provider("Cloudflare-Tunnel") == "cloudflared"
158 assert manager.normalize_provider("tailscale-funnel") == "tailscale"
159
160 with pytest.raises(ValueError, match="Unsupported remote control provider"):
161 manager.normalize_provider("lantern")
162
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
170 assert tailscale.command == [
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():
189 helper_files = {
190 "cloudflared": PROJECT_ROOT / "helpers/cloudflare_tunnel.py",
191 "microsoft": PROJECT_ROOT / "helpers/microsoft_tunnel.py",
192 "serveo": PROJECT_ROOT / "helpers/serveo_tunnel.py",
193 "tailscale": PROJECT_ROOT / "helpers/tailscale_tunnel.py",
194 }
195
196 assert all(path.exists() for path in helper_files.values())
197
198
199 def test_microsoft_dev_tunnel_uses_unique_a0_tunnel_id(tunnel_manager_module):
200 manager = tunnel_manager_module.TunnelManager()
201 tunnel = manager._create_tunnel(50001, "microsoft")
202
203 assert tunnel.start() == "https://example.test"
204
205 config = tunnel.tunnel.config.kwargs
206 assert config["tunnel_id"].startswith("agent-zero-")
207 assert config["tunnel_id"] != "flaredantic"
208 assert config["timeout"] == 120
209
210
211 def test_microsoft_dev_tunnel_id_can_be_overridden(
212 tunnel_manager_module,
213 monkeypatch,
214 ):
215 modules = remote_link_modules()
216 monkeypatch.setenv("A0_MICROSOFT_DEV_TUNNEL_ID", "agent-zero-custom")
217
218 assert modules.microsoft.default_microsoft_tunnel_id() == "agent-zero-custom"
219
220
221 def test_microsoft_dev_tunnel_timeout_error_is_enriched(
222 tunnel_manager_module,
223 ):
224 modules = remote_link_modules()
225
226 class FailingMicrosoftTunnel:
227 def __init__(self, config):
228 self.config = config
229
230 def start(self):
231 raise RuntimeError("Timeout waiting for Microsoft Dev Tunnels URL")
232
233 def stop(self):
234 return None
235
236 modules.microsoft.AgentZeroMicrosoftTunnel = FailingMicrosoftTunnel
237 tunnel = modules.microsoft.MicrosoftDevTunnel(50001)
238
239 with pytest.raises(RuntimeError, match="global `flaredantic` tunnel-id collision"):
240 tunnel.start()
241
242
243 def test_microsoft_dev_tunnel_emits_setup_progress_notifications(
244 tunnel_manager_module,
245 ):
246 modules = remote_link_modules()
247 config = FakeConfig(port=80, tunnel_id="agent-zero-test")
248 tunnel = modules.microsoft.AgentZeroMicrosoftTunnel(config)
249 commands = []
250
251 def fake_run_cmd(args):
252 commands.append(args)
253 if args[0] == "show" or args[:2] == ["port", "show"]:
254 return types.SimpleNamespace(returncode=1, stdout="missing")
255 return types.SimpleNamespace(returncode=0, stdout="ok")
256
257 tunnel._run_cmd = fake_run_cmd
258
259 tunnel._ensure_tunnel()
260
261 messages = [notification["message"] for notification in tunnel.notifications]
262 assert messages == [
263 "Checking Microsoft Dev Tunnel `agent-zero-test`...",
264 "Creating Microsoft Dev Tunnel `agent-zero-test`...",
265 "Checking Microsoft Dev Tunnel port 80...",
266 "Creating Microsoft Dev Tunnel port 80...",
267 "Microsoft Dev Tunnel setup is ready. Starting the secure host...",
268 ]
269 assert commands == [
270 ["show", "agent-zero-test"],
271 ["create", "agent-zero-test"],
272 ["port", "show", "agent-zero-test", "-p", "80"],
273 ["port", "create", "agent-zero-test", "-p", "80", "--protocol", "http"],
274 ]
275
276
277 @pytest.mark.parametrize(
278 ("provider", "expected_label"),
279 [
280 ("cloudflared", "Cloudflare Tunnel"),
281 ("microsoft", "Microsoft Dev Tunnels"),
282 ("serveo", "Serveo"),
283 ],
284 )
285 def test_flaredantic_provider_helpers_emit_manager_notifications(
286 tunnel_manager_module,
287 provider,
288 expected_label,
289 ):
290 manager = tunnel_manager_module.TunnelManager()
291 tunnel = manager._create_tunnel(50001, provider)
292
293 assert tunnel.start() == "https://example.test"
294
295 assert manager.notifications[0] == {
296 "event": "creating_tunnel",
297 "message": f"Starting {expected_label} on port 50001...",
298 "data": None,
299 }
300 assert manager.notifications[-1] == {
301 "event": "tunnel_url",
302 "message": f"{expected_label} URL is ready",
303 "data": {"url": "https://example.test"},
304 }
305
306
307 def test_zip_extraction_accepts_windows_exe_members(
308 tunnel_manager_module,
309 monkeypatch,
310 tmp_path,
311 ):
312 modules = remote_link_modules()
313 archive_path = tmp_path / "tailscale.zip"
314 destination = tmp_path / "bin"
315 write_zip_archive(archive_path, {"tailscale.exe": "binary"})
316 monkeypatch.setattr(modules.cli.platform, "system", lambda: "Windows")
317
318 extracted = modules.cli.extract_named_members_from_zip(
319 archive_path,
320 destination,
321 {"tailscale"},
322 )
323
324 assert extracted == {"tailscale": destination / "tailscale.exe"}
325 assert (destination / "tailscale.exe").read_text(encoding="utf-8") == "binary"
326
327
328 def test_tailscale_installs_runtime_binaries_from_static_archive(
329 tunnel_manager_module,
330 monkeypatch,
331 tmp_path,
332 ):
333 modules = remote_link_modules()
334 monkeypatch.setattr(modules.tailscale.shutil, "which", lambda binary: None)
335 monkeypatch.setattr(modules.cli, "RUNTIME_BIN_DIR", tmp_path / "bin")
336 monkeypatch.setattr(
337 modules.tailscale,
338 "tailscale_archive_url",
339 lambda: "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz",
340 )
341
342 def fake_download(url, destination, notify=None):
343 write_tar_archive(
344 destination,
345 {
346 "tailscale_1.84.0_amd64/tailscale": "#!/bin/sh\n",
347 "tailscale_1.84.0_amd64/tailscaled": "#!/bin/sh\n",
348 },
349 )
350 return destination
351
352 monkeypatch.setattr(modules.cli, "download_file", fake_download)
353
354 installed = Path(modules.tailscale.install_tailscale())
355
356 assert installed == tmp_path / "bin" / "tailscale"
357 assert (tmp_path / "bin" / "tailscaled").exists()
358 assert os.access(tmp_path / "bin" / "tailscale", os.X_OK)
359 assert os.access(tmp_path / "bin" / "tailscaled", os.X_OK)
360 assert not (tmp_path / "bin" / "tailscale_1.84.0_amd64.tgz").exists()
361
362
363 def test_tailscale_installer_downloads_static_pair_when_system_daemon_is_missing(
364 tunnel_manager_module,
365 monkeypatch,
366 tmp_path,
367 ):
368 modules = remote_link_modules()
369 monkeypatch.setattr(
370 modules.tailscale.shutil,
371 "which",
372 lambda binary: "/usr/bin/tailscale" if binary == "tailscale" else None,
373 )
374 monkeypatch.setattr(modules.cli, "RUNTIME_BIN_DIR", tmp_path / "bin")
375 monkeypatch.setattr(
376 modules.tailscale,
377 "tailscale_archive_url",
378 lambda: "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz",
379 )
380
381 def fake_download(url, destination, notify=None):
382 write_tar_archive(
383 destination,
384 {
385 "tailscale_1.84.0_amd64/tailscale": "#!/bin/sh\n",
386 "tailscale_1.84.0_amd64/tailscaled": "#!/bin/sh\n",
387 },
388 )
389 return destination
390
391 monkeypatch.setattr(modules.cli, "download_file", fake_download)
392
393 assert modules.tailscale.install_tailscale() == str(tmp_path / "bin" / "tailscale")
394 assert (tmp_path / "bin" / "tailscaled").exists()
395
396
397 def test_tailscale_static_package_url_is_discovered_from_official_listing(
398 tunnel_manager_module,
399 monkeypatch,
400 ):
401 modules = remote_link_modules()
402 html = (
403 '<a href="tailscale_1.84.0_arm64.tgz">arm</a>'
404 '<a href="tailscale_1.84.0_amd64.tgz">amd64</a>'
405 )
406
407 class FakeResponse:
408 def __enter__(self):
409 return self
410
411 def __exit__(self, exc_type, exc, traceback):
412 return None
413
414 def read(self):
415 return html.encode("utf-8")
416
417 monkeypatch.setattr(modules.tailscale, "tailscale_arch", lambda: "amd64")
418 monkeypatch.setattr(
419 modules.tailscale.urllib.request,
420 "urlopen",
421 lambda url, timeout=30: FakeResponse(),
422 )
423
424 assert (
425 modules.tailscale.tailscale_archive_url()
426 == "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz"
427 )
428
429
430 def test_tar_extraction_sanitizes_member_paths(
431 tunnel_manager_module,
432 tmp_path,
433 ):
434 modules = remote_link_modules()
435 archive_path = tmp_path / "tailscale.tgz"
436 destination = tmp_path / "safe"
437 write_tar_archive(archive_path, {"../tailscale": "binary"})
438
439 extracted = modules.cli.extract_named_members_from_tar(
440 archive_path,
441 destination,
442 {"tailscale"},
443 )
444
445 assert extracted == {"tailscale": destination / "tailscale"}
446 assert (destination / "tailscale").read_text(encoding="utf-8") == "binary"
447 assert not (tmp_path / "tailscale").exists()
448
449
450 @pytest.mark.parametrize(
451 ("provider", "module_name", "installer_name", "expected_message"),
452 [
453 ("tailscale", "tailscale", "install_tailscale", "tailscale download failed"),
454 ],
455 )
456 def test_cli_provider_installer_failures_return_actionable_error(
457 tunnel_manager_module,
458 monkeypatch,
459 provider,
460 module_name,
461 installer_name,
462 expected_message,
463 ):
464 manager_module = tunnel_manager_module
465 modules = remote_link_modules()
466 manager = manager_module.TunnelManager()
467
468 def fail_install(notify=None):
469 raise RuntimeError(expected_message)
470
471 monkeypatch.setattr(getattr(modules, module_name), installer_name, fail_install)
472
473 assert manager.start_tunnel(port=50001, provider=provider) is None
474 assert expected_message in manager.get_last_error()
475
476
477 def test_tailscale_preflight_starts_managed_daemon_then_runs_up_with_socket(
478 tunnel_manager_module,
479 monkeypatch,
480 tmp_path,
481 ):
482 modules = remote_link_modules()
483 runtime_dir = tmp_path / "runtime"
484 state_dir = tmp_path / "state"
485 socket_path = runtime_dir / "tailscaled.sock"
486 bin_dir = tmp_path / "bin"
487 bin_dir.mkdir()
488 tailscale = bin_dir / "tailscale"
489 tailscaled = bin_dir / "tailscaled"
490 tailscale.write_text("#!/bin/sh\n", encoding="utf-8")
491 tailscaled.write_text("#!/bin/sh\n", encoding="utf-8")
492 monkeypatch.setattr(modules.tailscale, "TAILSCALE_RUNTIME_DIR", runtime_dir)
493 monkeypatch.setattr(modules.tailscale, "TAILSCALE_STATE_DIR", state_dir)
494 monkeypatch.setattr(modules.tailscale, "TAILSCALE_SOCKET_PATH", socket_path)
495 monkeypatch.setattr(
496 modules.tailscale,
497 "TAILSCALE_DAEMON_LOG_PATH",
498 runtime_dir / "tailscaled.log",
499 )
500 monkeypatch.setattr(
501 modules.tailscale,
502 "TAILSCALE_DAEMON_PID_PATH",
503 runtime_dir / "tailscaled.pid",
504 )
505 status_results = iter([
506 types.SimpleNamespace(
507 returncode=1,
508 stdout="",
509 stderr="failed to connect to local tailscaled",
510 ),
511 types.SimpleNamespace(
512 returncode=1,
513 stdout="",
514 stderr="failed to connect to local tailscaled",
515 ),
516 types.SimpleNamespace(returncode=1, stdout="Logged out.", stderr=""),
517 types.SimpleNamespace(returncode=1, stdout="Logged out.", stderr=""),
518 types.SimpleNamespace(
519 returncode=0,
520 stdout='{"BackendState": "Running"}',
521 stderr="",
522 ),
523 ])
524 status_calls = []
525
526 def fake_status(binary_path, socket_path=None):
527 status_calls.append((binary_path, socket_path))
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:
543 def __init__(self, command, **kwargs):
544 popen_commands.append(command)
545 self.pid = 12345
546 self.is_tailscale_up = command[0] == str(tailscale)
547 self.stdout = iter(["Success.\n"]) if command[0] == str(tailscale) else None
548
549 def poll(self):
550 return 0 if self.is_tailscale_up else None
551
552 def terminate(self):
553 return None
554
555 def wait(self, timeout=None):
556 return 0
557
558 monkeypatch.setattr(modules.tailscale.subprocess, "Popen", FakeProcess)
559
560 result = modules.tailscale.ensure_tailscale_ready(str(tailscale))
561
562 assert result == {"command_prefix": ["--socket", str(socket_path)]}
563 assert status_calls == [
564 (str(tailscale), None),
565 (str(tailscale), socket_path),
566 (str(tailscale), socket_path),
567 (str(tailscale), socket_path),
568 (str(tailscale), socket_path),
569 ]
570 assert popen_commands == [
571 [
572 str(tailscaled),
573 "--tun=userspace-networking",
574 "--socket",
575 str(socket_path),
576 "--statedir",
577 str(state_dir),
578 "--state",
579 str(state_dir / "tailscaled.state"),
580 ],
581 [str(tailscale), "--socket", str(socket_path), "up"],
582 ]
583
584
585 def test_tailscale_preflight_emits_login_url_from_tailscale_up(
586 tunnel_manager_module,
587 monkeypatch,
588 ):
589 modules = remote_link_modules()
590 monkeypatch.setattr(
591 modules.tailscale,
592 "tailscale_status",
593 lambda binary_path, socket_path=None: types.SimpleNamespace(
594 returncode=1,
595 stdout="",
596 stderr="",
597 ),
598 )
599
600 class FakeProcess:
601 stdout = iter(
602 [
603 "To authenticate, visit:\n",
604 "https://login.tailscale.com/a/abcdef\n",
605 ]
606 )
607
608 def poll(self):
609 return 1
610
611 def terminate(self):
612 return None
613
614 def wait(self, timeout=None):
615 return 1
616
617 notifications = []
618 monkeypatch.setattr(
619 modules.tailscale.subprocess,
620 "Popen",
621 lambda *args, **kwargs: FakeProcess(),
622 )
623
624 with pytest.raises(RuntimeError, match="joining this container to your tailnet"):
625 modules.tailscale.ensure_tailscale_ready(
626 "/tmp/tailscale",
627 notify=lambda event, message, data=None: notifications.append(
628 {"event": event.value, "message": message, "data": data}
629 ),
630 )
631
632 assert notifications[0]["message"] == (
633 "Tailscale needs this container to join your tailnet. Running `tailscale up` now..."
634 )
635 assert notifications[1] == {
636 "event": "info",
637 "message": (
638 "Open the Tailscale login link and approve this container. "
639 "Agent Zero will continue when Tailscale finishes setup."
640 ),
641 "data": {
642 "provider": "tailscale",
643 "url": "https://login.tailscale.com/a/abcdef",
644 },
645 }
646
647
648 def test_tailscale_preflight_runs_up_then_accepts_running_status(
649 tunnel_manager_module,
650 monkeypatch,
651 ):
652 modules = remote_link_modules()
653 status_results = iter([
654 types.SimpleNamespace(returncode=1, stdout="", stderr="not logged in"),
655 types.SimpleNamespace(
656 returncode=0,
657 stdout='{"BackendState": "Running"}',
658 stderr="",
659 ),
660 ])
661 notifications = []
662
663 monkeypatch.setattr(
664 modules.tailscale,
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"])
680
681 def poll(self):
682 return 0
683
684 def wait(self, timeout=None):
685 return 0
686
687 monkeypatch.setattr(
688 modules.tailscale.subprocess,
689 "Popen",
690 lambda *args, **kwargs: FakeProcess(),
691 )
692
693 modules.tailscale.ensure_tailscale_ready(
694 "/tmp/tailscale",
695 notify=lambda event, message, data=None: notifications.append(
696 {"event": event.value, "message": message, "data": data}
697 ),
698 )
699
700 assert notifications[-1] == {
701 "event": "info",
702 "message": "Tailscale setup completed. Checking the tailnet connection...",
703 "data": None,
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,
814 ):
815 modules = remote_link_modules()
816 popen_commands = []
817 run_commands = []
818
819 class FakeProcess:
820 def __init__(self, command, **kwargs):
821 popen_commands.append(command)
822 self.stdout = iter(["https://agent-zero.ts.net\n"])
823
824 def poll(self):
825 return None
826
827 def terminate(self):
828 return None
829
830 def wait(self, timeout=None):
831 return 0
832
833 monkeypatch.setattr(modules.cli.subprocess, "Popen", FakeProcess)
834 monkeypatch.setattr(
835 modules.cli.subprocess,
836 "run",
837 lambda command, **kwargs: run_commands.append(command)
838 or types.SimpleNamespace(returncode=0),
839 )
840
841 tunnel = modules.cli.CliTunnelHelper(
842 label="Tailscale Funnel",
843 binary="tailscale",
844 port=50001,
845 command=["tailscale", "funnel", "--yes", "http://127.0.0.1:50001"],
846 shutdown_command=[
847 "tailscale",
848 "funnel",
849 "--yes",
850 "http://127.0.0.1:50001",
851 "off",
852 ],
853 url_pattern=modules.tailscale.TAILSCALE_URL_RE,
854 missing_binary_message="missing",
855 binary_resolver=lambda notify=None: "/tmp/tailscale",
856 preflight=lambda binary_path, notify=None: {
857 "command_prefix": ["--socket", "/tmp/tailscaled.sock"]
858 },
859 )
860
861 assert tunnel.start() == "https://agent-zero.ts.net"
862 tunnel.stop()
863
864 assert popen_commands == [
865 [
866 "/tmp/tailscale",
867 "--socket",
868 "/tmp/tailscaled.sock",
869 "funnel",
870 "--yes",
871 "http://127.0.0.1:50001",
872 ]
873 ]
874 assert run_commands == [
875 [
876 "/tmp/tailscale",
877 "--socket",
878 "/tmp/tailscaled.sock",
879 "funnel",
880 "--yes",
881 "http://127.0.0.1:50001",
882 "off",
883 ]
884 ]