main
py 494 lines 16.7 KB
Raw
1 from collections import deque
2 import json
3 import os
4 import queue
5 import re
6 import shutil
7 import subprocess
8 import threading
9 import time
10 import urllib.parse
11 import urllib.request
12 from pathlib import Path
13
14 from flaredantic import NotifyEvent
15
16 from helpers import cli_tunnel, files
17 from helpers.cli_tunnel import CliTunnelHelper
18
19
20 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"))
29 TAILSCALE_SOCKET_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.sock"
30 TAILSCALE_DAEMON_LOG_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.log"
31 TAILSCALE_DAEMON_PID_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.pid"
32
33
34 def tailscale_arch():
35 _, arch = cli_tunnel.platform_parts()
36 return arch
37
38
39 def tailscale_archive_url():
40 arch = tailscale_arch()
41 with urllib.request.urlopen(TAILSCALE_STABLE_PACKAGES_URL, timeout=30) as response:
42 html = response.read().decode("utf-8", errors="replace")
43 pattern = re.compile(rf'href="([^"]*tailscale_[^"]+_{re.escape(arch)}\.tgz)"')
44 match = pattern.search(html)
45 if not match:
46 raise RuntimeError(f"Could not find a Tailscale static binary for {arch}.")
47 return urllib.parse.urljoin(TAILSCALE_STABLE_PACKAGES_URL, match.group(1))
48
49
50 def install_tailscale(notify=None):
51 existing = shutil.which("tailscale")
52 if existing and resolve_tailscaled_binary(existing):
53 return existing
54
55 install_path = cli_tunnel.RUNTIME_BIN_DIR / cli_tunnel.executable_name("tailscale")
56 daemon_path = cli_tunnel.RUNTIME_BIN_DIR / cli_tunnel.executable_name("tailscaled")
57 if install_path.exists() and daemon_path.exists():
58 return str(install_path)
59
60 download_url = tailscale_archive_url()
61 archive_path = (
62 cli_tunnel.RUNTIME_BIN_DIR
63 / urllib.parse.urlparse(download_url).path.rsplit("/", 1)[-1]
64 )
65 cli_tunnel.download_file(download_url, archive_path, notify=notify)
66 try:
67 extracted = cli_tunnel.extract_named_members_from_tar(
68 archive_path,
69 cli_tunnel.RUNTIME_BIN_DIR,
70 {"tailscale", "tailscaled"},
71 )
72 return str(extracted["tailscale"])
73 finally:
74 archive_path.unlink(missing_ok=True)
75
76
77 def resolve_tailscaled_binary(binary_path):
78 sibling = Path(binary_path).with_name(cli_tunnel.executable_name("tailscaled"))
79 if sibling.exists():
80 return str(sibling)
81 return shutil.which("tailscaled")
82
83
84 def notify_info(notify, message, data=None):
85 if callable(notify):
86 notify(NotifyEvent.INFO, message, data)
87
88
89 def tailscale_socket_args(socket_path=None):
90 return ["--socket", str(socket_path)] if socket_path else []
91
92
93 def tailscale_command(binary_path, args, socket_path=None):
94 return [binary_path, *tailscale_socket_args(socket_path), *args]
95
96
97 def tailscale_status(binary_path, socket_path=None):
98 return subprocess.run(
99 tailscale_command(binary_path, ["status", "--json"], socket_path),
100 check=False,
101 text=True,
102 capture_output=True,
103 timeout=12,
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
120
121 def tailscale_daemon_hint(output):
122 lowered = output.lower()
123 return any(
124 marker in lowered
125 for marker in (
126 "failed to connect to local tailscaled",
127 "tailscaled.sock",
128 "no such file or directory",
129 "connection refused",
130 "is tailscaled running",
131 )
132 )
133
134
135 def tailscale_daemon_ready(binary_path, socket_path):
136 completed = tailscale_status(binary_path, socket_path=socket_path)
137 output = compact_output([completed.stderr, completed.stdout])
138 return not tailscale_daemon_hint(output)
139
140
141 def read_recent_tailscaled_log():
142 if not TAILSCALE_DAEMON_LOG_PATH.exists():
143 return ""
144 try:
145 lines = TAILSCALE_DAEMON_LOG_PATH.read_text(
146 encoding="utf-8",
147 errors="replace",
148 ).splitlines()
149 except OSError:
150 return ""
151 return compact_output(lines[-12:])
152
153
154 def start_tailscaled(binary_path, notify=None):
155 daemon_path = resolve_tailscaled_binary(binary_path)
156 if not daemon_path:
157 raise RuntimeError(
158 "Tailscale was prepared, but Agent Zero could not find the "
159 "`tailscaled` daemon binary. Try Tailscale Remote Control again so "
160 "Agent Zero can re-download the static Tailscale package."
161 )
162
163 if tailscale_daemon_ready(binary_path, TAILSCALE_SOCKET_PATH):
164 return TAILSCALE_SOCKET_PATH
165
166 TAILSCALE_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
167 TAILSCALE_STATE_DIR.mkdir(parents=True, exist_ok=True)
168 TAILSCALE_SOCKET_PATH.unlink(missing_ok=True)
169
170 notify_info(
171 notify,
172 "Starting Tailscale's background service inside this container...",
173 )
174
175 log_handle = TAILSCALE_DAEMON_LOG_PATH.open("ab")
176 process = subprocess.Popen(
177 [
178 daemon_path,
179 "--tun=userspace-networking",
180 "--socket",
181 str(TAILSCALE_SOCKET_PATH),
182 "--statedir",
183 str(TAILSCALE_STATE_DIR),
184 "--state",
185 str(TAILSCALE_STATE_DIR / "tailscaled.state"),
186 ],
187 stdout=log_handle,
188 stderr=subprocess.STDOUT,
189 stdin=subprocess.DEVNULL,
190 start_new_session=True,
191 close_fds=True,
192 )
193 log_handle.close()
194 TAILSCALE_DAEMON_PID_PATH.write_text(str(process.pid), encoding="utf-8")
195
196 deadline = time.monotonic() + TAILSCALE_DAEMON_START_TIMEOUT
197 while time.monotonic() < deadline:
198 if process.poll() is not None:
199 break
200 if tailscale_daemon_ready(binary_path, TAILSCALE_SOCKET_PATH):
201 notify_info(notify, "Tailscale background service is running.")
202 return TAILSCALE_SOCKET_PATH
203 time.sleep(0.25)
204
205 details = read_recent_tailscaled_log()
206 if process.poll() is None:
207 process.terminate()
208 try:
209 process.wait(timeout=5)
210 except subprocess.TimeoutExpired:
211 process.kill()
212 process.wait(timeout=3)
213 TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
214 message = (
215 "Agent Zero downloaded Tailscale, but could not start the `tailscaled` "
216 "background service in this container."
217 )
218 if details:
219 message = f"{message} Details: {details}"
220 raise RuntimeError(message)
221
222
223 def stop_managed_tailscaled():
224 if not TAILSCALE_DAEMON_PID_PATH.exists():
225 return
226 try:
227 pid = int(TAILSCALE_DAEMON_PID_PATH.read_text(encoding="utf-8").strip())
228 except Exception:
229 TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
230 return
231 try:
232 cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().decode(
233 "utf-8",
234 errors="replace",
235 )
236 except Exception:
237 cmdline = ""
238 if "tailscaled" in cmdline:
239 try:
240 os.kill(pid, 15)
241 deadline = time.monotonic() + 5
242 while time.monotonic() < deadline:
243 try:
244 os.kill(pid, 0)
245 except ProcessLookupError:
246 break
247 time.sleep(0.1)
248 else:
249 os.kill(pid, 9)
250 except ProcessLookupError:
251 pass
252 except PermissionError:
253 pass
254 TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
255 TAILSCALE_SOCKET_PATH.unlink(missing_ok=True)
256
257
258 def tailscale_up_failure_message(output, *, timed_out=False):
259 details = compact_output(output)
260 if tailscale_daemon_hint(details):
261 message = (
262 "Agent Zero started Tailscale and ran `tailscale up`, but the "
263 "Tailscale background service stopped responding."
264 )
265 elif timed_out:
266 message = (
267 "Agent Zero ran `tailscale up`, but it did not finish before the "
268 "setup timeout. If a Tailscale login link was shown, approve this "
269 "container in your browser, then start Remote Control again."
270 )
271 else:
272 message = (
273 "Agent Zero ran `tailscale up`, but Tailscale did not finish joining "
274 "this container to your tailnet. Complete any Tailscale login or "
275 "admin approval it requested, then try Tailscale Remote Control again."
276 )
277 if details:
278 message = f"{message} Details: {details}"
279 return message
280
281
282 def run_tailscale_up(
283 binary_path,
284 notify=None,
285 timeout=TAILSCALE_UP_TIMEOUT,
286 socket_path=None,
287 ):
288 notify_info(
289 notify,
290 "Tailscale needs this container to join your tailnet. Running `tailscale up` now...",
291 )
292 process = subprocess.Popen(
293 tailscale_command(binary_path, ["up"], socket_path),
294 stdout=subprocess.PIPE,
295 stderr=subprocess.STDOUT,
296 text=True,
297 bufsize=1,
298 )
299 output_queue = queue.Queue()
300
301 def read_output():
302 if process.stdout is None:
303 return
304 for line in process.stdout:
305 output_queue.put(line)
306
307 reader = threading.Thread(target=read_output, daemon=True)
308 reader.start()
309 deadline = time.monotonic() + timeout
310 recent_output = deque(maxlen=10)
311 login_announced = False
312
313 def record_line(line):
314 nonlocal login_announced
315 cleaned = line.strip()
316 if not cleaned:
317 return
318 recent_output.append(cleaned)
319 login_match = TAILSCALE_LOGIN_URL_RE.search(cleaned)
320 if login_match and not login_announced:
321 login_url = login_match.group(0).rstrip(".,)")
322 notify_info(
323 notify,
324 "Open the Tailscale login link and approve this container. "
325 "Agent Zero will continue when Tailscale finishes setup.",
326 {"provider": "tailscale", "url": login_url},
327 )
328 login_announced = True
329
330 while True:
331 try:
332 record_line(output_queue.get(timeout=0.1))
333 except queue.Empty:
334 if process.poll() is not None:
335 reader.join(timeout=1)
336 while True:
337 try:
338 record_line(output_queue.get_nowait())
339 except queue.Empty:
340 break
341 break
342 if time.monotonic() >= deadline:
343 process.terminate()
344 try:
345 process.wait(timeout=8)
346 except subprocess.TimeoutExpired:
347 process.kill()
348 process.wait(timeout=3)
349 reader.join(timeout=1)
350 while True:
351 try:
352 record_line(output_queue.get_nowait())
353 except queue.Empty:
354 break
355 raise RuntimeError(
356 tailscale_up_failure_message(recent_output, timed_out=True)
357 )
358 continue
359
360 returncode = process.wait(timeout=3)
361 if returncode != 0:
362 raise RuntimeError(tailscale_up_failure_message(recent_output))
363
364 notify_info(
365 notify,
366 "Tailscale setup completed. Checking the tailnet connection...",
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)
388 if completed.returncode != 0 and tailscale_daemon_hint(
389 compact_output([completed.stderr, completed.stdout])
390 ):
391 socket_path = start_tailscaled(binary_path, notify=notify)
392 completed = tailscale_status(binary_path, socket_path=socket_path)
393
394 if completed.returncode != 0:
395 run_tailscale_up(binary_path, notify=notify, socket_path=socket_path)
396 completed = tailscale_status(binary_path, socket_path=socket_path)
397 if completed.returncode != 0:
398 details = compact_output([completed.stderr, completed.stdout])
399 message = (
400 "Agent Zero ran `tailscale up`, but Tailscale status is still "
401 "not available. Tailscale may still need browser approval, admin "
402 "approval, or a running `tailscaled` service."
403 )
404 if details:
405 message = f"{message} Details: {details}"
406 raise RuntimeError(message)
407
408 try:
409 payload = json.loads(completed.stdout or "{}")
410 except json.JSONDecodeError:
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":
416 run_tailscale_up(binary_path, notify=notify, socket_path=socket_path)
417 completed = tailscale_status(binary_path, socket_path=socket_path)
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":
425 raise RuntimeError(
426 "Agent Zero ran `tailscale up`, but this node is still not ready "
427 "for Tailscale Funnel "
428 f"(state: {backend_state}). Complete Tailscale sign-in or admin "
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,
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 ),
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()
492 finally:
493 if self.command_prefix:
494 stop_managed_tailscaled()