tailscale.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """Tailscale presence detection for virtual printers.
  2. Reports whether tailscaled is reachable and surfaces the host's Tailscale IPs
  3. and FQDN so the UI can show users which IP to paste into the slicer when
  4. they want to reach a VP over Tailscale.
  5. Historical note: this module previously provisioned Let's Encrypt certs via
  6. `tailscale cert` so the slicer would not need a manual CA import. That path
  7. was removed because LE-signed certs can't help on two independent dimensions:
  8. (1) BambuStudio / OrcaSlicer printer-MQTT trust validates only against the
  9. bundled BBL CA, not the system trust store, so non-BBL chains are rejected
  10. at the issuer check; (2) both slicers' Add Printer dialog accepts only an
  11. IP address (not a hostname), so even if the trust store accepted the LE
  12. issuer, the cert's hostname (`*.<tailnet>.ts.net`) couldn't match the
  13. `100.x.x.x` connection target. The self-signed CA flow (one-time `bbl_ca.crt`
  14. import into the slicer) is the only viable trust mechanism; Tailscale's role
  15. is now strictly network reach.
  16. """
  17. import asyncio
  18. import json
  19. import logging
  20. import os
  21. import shutil
  22. from dataclasses import dataclass, field
  23. from pathlib import Path
  24. logger = logging.getLogger(__name__)
  25. # Minimal environment for tailscale subprocess — passes OS/shell variables that
  26. # tailscale needs to locate its socket and config, but strips application secrets
  27. # (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for.
  28. _SUBPROCESS_ENV: dict[str, str] = {
  29. k: v
  30. for k, v in os.environ.items()
  31. if k
  32. in {
  33. "PATH",
  34. "HOME",
  35. "USER",
  36. "USERNAME",
  37. "LOGNAME",
  38. # Windows equivalents
  39. "USERPROFILE",
  40. "APPDATA",
  41. "LOCALAPPDATA",
  42. "PROGRAMFILES",
  43. "PROGRAMFILES(X86)",
  44. "SYSTEMROOT",
  45. "WINDIR",
  46. "COMPUTERNAME",
  47. "TEMP",
  48. "TMP",
  49. # Linux XDG dirs used by tailscale for socket/config
  50. "XDG_RUNTIME_DIR",
  51. "XDG_CONFIG_HOME",
  52. }
  53. }
  54. @dataclass
  55. class TailscaleStatus:
  56. """Runtime Tailscale availability and identity."""
  57. available: bool
  58. hostname: str # "myhost"
  59. tailnet_name: str # "tailnetname.ts.net"
  60. fqdn: str # "myhost.tailnetname.ts.net"
  61. tailscale_ips: list[str] = field(default_factory=list)
  62. error: str | None = None
  63. class TailscaleService:
  64. """Wraps `tailscale status` for presence detection.
  65. All methods are safe to call when Tailscale is absent — they return
  66. sensible defaults and never raise exceptions.
  67. """
  68. _docker_hint_logged: bool = False
  69. @classmethod
  70. def _log_docker_socket_hint(cls) -> None:
  71. """Log a one-time hint when running in Docker without the Tailscale socket mounted."""
  72. if cls._docker_hint_logged:
  73. return
  74. if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
  75. logger.info(
  76. "Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
  77. "Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` "
  78. "to docker-compose.yml (under volumes:) and run Tailscale on the host to "
  79. "expose virtual printers over your tailnet."
  80. )
  81. cls._docker_hint_logged = True
  82. async def _run_tailscale(self, *args: str, timeout: float = 30.0) -> tuple[int | None, bytes, bytes]:
  83. """Run a tailscale subcommand and return (returncode, stdout, stderr).
  84. Resolves the binary to an absolute path to guard against PATH hijacking.
  85. """
  86. binary = shutil.which("tailscale")
  87. if not binary:
  88. raise OSError("tailscale binary not found")
  89. process = await asyncio.create_subprocess_exec(
  90. binary,
  91. *args,
  92. stdout=asyncio.subprocess.PIPE,
  93. stderr=asyncio.subprocess.PIPE,
  94. env=_SUBPROCESS_ENV,
  95. )
  96. try:
  97. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  98. except asyncio.TimeoutError:
  99. process.kill()
  100. await process.wait()
  101. raise
  102. return process.returncode, stdout, stderr
  103. async def get_status(self) -> TailscaleStatus:
  104. """Query Tailscale status and return machine identity.
  105. Returns TailscaleStatus(available=False) if the binary is missing,
  106. the daemon is not running, or any other error occurs.
  107. """
  108. if not shutil.which("tailscale"):
  109. self._log_docker_socket_hint()
  110. return TailscaleStatus(
  111. available=False,
  112. hostname="",
  113. tailnet_name="",
  114. fqdn="",
  115. error="tailscale binary not found",
  116. )
  117. try:
  118. returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
  119. except OSError as e:
  120. return TailscaleStatus(
  121. available=False,
  122. hostname="",
  123. tailnet_name="",
  124. fqdn="",
  125. error=str(e),
  126. )
  127. if returncode is None or returncode != 0:
  128. self._log_docker_socket_hint()
  129. return TailscaleStatus(
  130. available=False,
  131. hostname="",
  132. tailnet_name="",
  133. fqdn="",
  134. error=stderr.decode(errors="replace").strip(),
  135. )
  136. try:
  137. data = json.loads(stdout)
  138. except json.JSONDecodeError as e:
  139. return TailscaleStatus(
  140. available=False,
  141. hostname="",
  142. tailnet_name="",
  143. fqdn="",
  144. error=f"JSON parse error: {e}",
  145. )
  146. self_info = data.get("Self", {})
  147. # DNSName includes trailing dot: "myhost.tailnetname.ts.net."
  148. fqdn = self_info.get("DNSName", "").rstrip(".")
  149. if not fqdn:
  150. return TailscaleStatus(
  151. available=False,
  152. hostname="",
  153. tailnet_name="",
  154. fqdn="",
  155. error="Tailscale not connected (no DNSName)",
  156. )
  157. parts = fqdn.split(".", 1)
  158. hostname = parts[0]
  159. tailnet_name = parts[1] if len(parts) > 1 else ""
  160. tailscale_ips = self_info.get("TailscaleIPs", [])
  161. logger.debug("Tailscale available: fqdn=%s, ips=%s", fqdn, tailscale_ips)
  162. return TailscaleStatus(
  163. available=True,
  164. hostname=hostname,
  165. tailnet_name=tailnet_name,
  166. fqdn=fqdn,
  167. tailscale_ips=tailscale_ips,
  168. )
  169. # Module-level singleton — import this in other modules
  170. tailscale_service = TailscaleService()