Files
bambuddy/backend/app/services/virtual_printer/tailscale.py
T
maziggy 64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
2026-05-03 14:58:29 +02:00

194 lines
6.3 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 BambuStudio's printer-MQTT trust path validates only
against its bundled BBL CA (not the system trust store), so LE-signed certs
are rejected regardless of hostname/IP. The self-signed CA flow (with 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 as e:
return TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error=str(e),
)
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()