bambuddy/backend/app/services/virtual_printer/tailscale.py
maziggy 597762685c fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting
  the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...)
  leak through. Bambu Studio's Send pre-flight read them as busy and refused.
  The cached branch now overrides the activity-field set the same way it
  already overrode storage indicators (#1228) and protocol fields.

  Same bundle ships a multi-round VP audit that found adjacent bugs in the
  same family:

  - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error
  - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log
  - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload)
  - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status
  - _pending_files cleanup in finally for archive / queue / dispatch handlers
  - _add_to_print_queue position uses MAX+1 (was hardcoded 1)
  - DELETE VP removes orphan PendingUpload rows + upload_dir from disk
  - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match)
  - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs
  - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map)
  - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind
  - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation)
  - FTP passive port range widened 50000-51000; docker-compose + wiki updated
  - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
2026-05-30 13:34:10 +02:00

202 lines
7 KiB
Python

"""Tailscale presence detection for virtual printers.
Reports whether tailscaled is reachable and surfaces the host's Tailscale IPs
and FQDN so the UI can show users which IP to paste into the slicer when
they want to reach a VP over Tailscale.
Historical note: this module previously provisioned Let's Encrypt certs via
`tailscale cert` so the slicer would not need a manual CA import. That path
was removed because LE-signed certs can't help on two independent dimensions:
(1) BambuStudio / OrcaSlicer printer-MQTT trust validates only against the
bundled BBL CA, not the system trust store, so non-BBL chains are rejected
at the issuer check; (2) both slicers' Add Printer dialog accepts only an
IP address (not a hostname), so even if the trust store accepted the LE
issuer, the cert's hostname (`*.<tailnet>.ts.net`) couldn't match the
`100.x.x.x` connection target. The self-signed CA flow (one-time `bbl_ca.crt`
import into the slicer) is the only viable trust mechanism; Tailscale's role
is now strictly network reach.
"""
import asyncio
import json
import logging
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
# Minimal environment for tailscale subprocess — passes OS/shell variables that
# tailscale needs to locate its socket and config, but strips application secrets
# (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for.
_SUBPROCESS_ENV: dict[str, str] = {
k: v
for k, v in os.environ.items()
if k
in {
"PATH",
"HOME",
"USER",
"USERNAME",
"LOGNAME",
# Windows equivalents
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"SYSTEMROOT",
"WINDIR",
"COMPUTERNAME",
"TEMP",
"TMP",
# Linux XDG dirs used by tailscale for socket/config
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
}
}
@dataclass
class TailscaleStatus:
"""Runtime Tailscale availability and identity."""
available: bool
hostname: str # "myhost"
tailnet_name: str # "tailnetname.ts.net"
fqdn: str # "myhost.tailnetname.ts.net"
tailscale_ips: list[str] = field(default_factory=list)
error: str | None = None
class TailscaleService:
"""Wraps `tailscale status` for presence detection.
All methods are safe to call when Tailscale is absent — they return
sensible defaults and never raise exceptions.
"""
_docker_hint_logged: bool = False
@classmethod
def _log_docker_socket_hint(cls) -> None:
"""Log a one-time hint when running in Docker without the Tailscale socket mounted."""
if cls._docker_hint_logged:
return
if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
logger.info(
"Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
"Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` "
"to docker-compose.yml (under volumes:) and run Tailscale on the host to "
"expose virtual printers over your tailnet."
)
cls._docker_hint_logged = True
async def _run_tailscale(self, *args: str, timeout: float = 30.0) -> tuple[int | None, bytes, bytes]:
"""Run a tailscale subcommand and return (returncode, stdout, stderr).
Resolves the binary to an absolute path to guard against PATH hijacking.
"""
binary = shutil.which("tailscale")
if not binary:
raise OSError("tailscale binary not found")
process = await asyncio.create_subprocess_exec(
binary,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_SUBPROCESS_ENV,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
await process.wait()
raise
return process.returncode, stdout, stderr
async def get_status(self) -> TailscaleStatus:
"""Query Tailscale status and return machine identity.
Returns TailscaleStatus(available=False) if the binary is missing,
the daemon is not running, or any other error occurs.
"""
if not shutil.which("tailscale"):
self._log_docker_socket_hint()
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error="tailscale binary not found",
)
try:
returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
except (OSError, asyncio.TimeoutError) as e:
# asyncio.TimeoutError covers the case where ``_run_tailscale``
# killed a stuck subprocess and re-raised. Without this branch
# the timeout escaped into the FastAPI route handler and could
# crash the VP management UI for users with a lagging
# tailscaled daemon.
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error=str(e) or "tailscale status timed out",
)
if returncode is None or returncode != 0:
self._log_docker_socket_hint()
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error=stderr.decode(errors="replace").strip(),
)
try:
data = json.loads(stdout)
except json.JSONDecodeError as e:
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error=f"JSON parse error: {e}",
)
self_info = data.get("Self", {})
# DNSName includes trailing dot: "myhost.tailnetname.ts.net."
fqdn = self_info.get("DNSName", "").rstrip(".")
if not fqdn:
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error="Tailscale not connected (no DNSName)",
)
parts = fqdn.split(".", 1)
hostname = parts[0]
tailnet_name = parts[1] if len(parts) > 1 else ""
tailscale_ips = self_info.get("TailscaleIPs", [])
logger.debug("Tailscale available: fqdn=%s, ips=%s", fqdn, tailscale_ips)
return TailscaleStatus(
available=True,
hostname=hostname,
tailnet_name=tailnet_name,
fqdn=fqdn,
tailscale_ips=tailscale_ips,
)
# Module-level singleton — import this in other modules
tailscale_service = TailscaleService()