Browse Source

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.
maziggy 3 weeks ago
parent
commit
64899a8ca4

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 1 - 1
README.md

@@ -60,7 +60,7 @@ You don't need to be a developer for the docs or moderator roles. If you enjoy w
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
 - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
-- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount brings Bambuddy into your tailnet, so virtual printers are reachable from any tailnet device over a private WireGuard tunnel without port forwarding ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side because both Bambu Studio and OrcaSlicer only accept IP addresses in the Add Printer dialog — the Tailscale benefit here is the tunnel, not cert-import elimination.
+- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side — the Bambu Studio / OrcaSlicer printer-MQTT trust path uses a bundled BBL CA, not the system trust store, so even a publicly-trusted cert wouldn't help. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination.
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting

+ 0 - 15
backend/app/api/routes/settings.py

@@ -928,21 +928,6 @@ async def update_virtual_printer_settings(
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
-    # Guard: enabling Tailscale (disabled=False) requires the binary to be present. Otherwise
-    # the toggle looks like it worked but the service will silently fall back to self-signed.
-    if tailscale_disabled is False and current_ts_disabled is True:
-        from backend.app.services.virtual_printer.tailscale import tailscale_service
-
-        ts_status = await tailscale_service.get_status()
-        if not ts_status.available:
-            return JSONResponse(
-                status_code=409,
-                content={
-                    "detail": "tailscale_not_available",
-                    "reason": ts_status.error or "tailscale binary not found",
-                },
-            )
-
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "print_queue" archives and adds to print queue (unassigned)

+ 0 - 14
backend/app/api/routes/virtual_printers.py

@@ -342,20 +342,6 @@ async def update_virtual_printer(
     if body.remote_interface_ip is not None:
         vp.remote_interface_ip = body.remote_interface_ip
     if body.tailscale_disabled is not None:
-        # Guard: user trying to enable Tailscale (disabled=False) must have the binary available.
-        # Otherwise the toggle looks like it works but silently falls back to self-signed.
-        if body.tailscale_disabled is False and vp.tailscale_disabled is True:
-            from backend.app.services.virtual_printer.tailscale import tailscale_service
-
-            ts_status = await tailscale_service.get_status()
-            if not ts_status.available:
-                return JSONResponse(
-                    status_code=409,
-                    content={
-                        "detail": "tailscale_not_available",
-                        "reason": ts_status.error or "tailscale binary not found",
-                    },
-                )
         vp.tailscale_disabled = body.tailscale_disabled
 
     # Auto-inherit model when switching to proxy mode with existing target printer

+ 0 - 27
backend/app/services/virtual_printer/certificate.py

@@ -330,33 +330,6 @@ class CertificateService:
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
 
-    # -- Tailscale cert support --
-
-    @property
-    def ts_cert_path(self) -> Path:
-        """Path for Tailscale-provisioned cert (separate from self-signed)."""
-        return self.cert_dir / "virtual_printer_ts.crt"
-
-    @property
-    def ts_key_path(self) -> Path:
-        """Path for Tailscale-provisioned private key."""
-        return self.cert_dir / "virtual_printer_ts.key"
-
-    async def use_tailscale_cert(
-        self,
-        fqdn: str,
-        tailscale_svc: object,
-    ) -> tuple[Path, Path] | None:
-        """Attempt to provision a Tailscale LE cert for fqdn.
-
-        Delegates to tailscale_svc.ensure_cert(). Returns (cert_path, key_path)
-        on success, None if Tailscale provisioning fails.
-        """
-        ok = await tailscale_svc.ensure_cert(fqdn, self.ts_cert_path, self.ts_key_path)
-        if ok:
-            return self.ts_cert_path, self.ts_key_path
-        return None
-
     def delete_printer_certificate(self) -> None:
         """Delete only the printer certificate (preserves CA)."""
         for path in [self.cert_path, self.key_path]:

+ 7 - 149
backend/app/services/virtual_printer/manager.py

@@ -18,7 +18,6 @@ from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPSer
 from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
-from backend.app.services.virtual_printer.tailscale import tailscale_service
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
 
 if TYPE_CHECKING:
@@ -158,9 +157,6 @@ class VirtualPrinterInstance:
             shared_ca_dir=shared_ca_dir,
         )
 
-        # Tailscale FQDN used for this instance (set at start_server/start_proxy time)
-        self.tailscale_fqdn: str | None = None
-
         # Pending files for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
@@ -174,8 +170,6 @@ class VirtualPrinterInstance:
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp_proxy: SSDPProxy | None = None
         self._tasks: list[asyncio.Task] = []
-        self._cert_renewal_task: asyncio.Task | None = None
-        self._cert_restart_task: asyncio.Task | None = None
 
     @property
     def serial(self) -> str:
@@ -447,135 +441,14 @@ class VirtualPrinterInstance:
 
     # -- Service lifecycle --
 
-    async def _cancel_renewal_task(self) -> None:
-        """Cancel the cert renewal task and await its completion."""
-        if self._cert_renewal_task:
-            self._cert_renewal_task.cancel()
-            try:
-                await self._cert_renewal_task
-            except asyncio.CancelledError:
-                pass
-            except Exception as e:
-                logger.warning("[VP %s] Unexpected error in cert renewal task: %s", self.name, e)
-            self._cert_renewal_task = None
-
-    async def _cancel_restart_task(self) -> None:
-        """Cancel the cert restart task and await its completion.
-
-        Skip when the caller IS the restart task itself — stop_server() /
-        stop_proxy() are called from inside _restart_for_cert_renewal,
-        which runs AS _cert_restart_task. Cancelling + awaiting self
-        flags a CancelledError on the next `await` in stop_server,
-        which tears down the old listeners but never lets start_server
-        run — the VP would sit on an expired cert until process restart.
-        """
-        task = self._cert_restart_task
-        if task is asyncio.current_task():
-            # Renewal path cleaning up its own restart task: clear the
-            # reference so future callers don't see a stale task handle,
-            # but do NOT cancel-and-await ourselves.
-            self._cert_restart_task = None
-            return
-        if task and not task.done():
-            task.cancel()
-            try:
-                await task
-            except asyncio.CancelledError:
-                pass
-            except Exception as e:
-                logger.warning("[VP %s] Unexpected error in cert restart task: %s", self.name, e)
-            self._cert_restart_task = None
-
-    async def _restart_for_cert_renewal(self) -> None:
-        """Restart VP services to load the newly renewed Tailscale cert into TLS listeners."""
-        logger.info("[VP %s] Restarting services to apply renewed Tailscale cert", self.name)
-        try:
-            if self.is_proxy:
-                await self.stop_proxy()
-                await self.start_proxy()
-            else:
-                await self.stop_server()
-                await self.start_server()
-        except asyncio.CancelledError:
-            raise
-        except Exception as e:
-            logger.error("[VP %s] Failed to restart after cert renewal: %s", self.name, e)
-
-    async def _cert_renewal_loop(self) -> None:
-        """Daily background check for Tailscale cert renewal while VP is running.
-
-        Checks first, then sleeps, so a cert that was just barely renewed at startup
-        is not re-checked for another 24 h. When a renewal actually happens the loop
-        schedules a VP restart so the new cert is loaded into the running TLS listeners.
-
-        _cert_renewal_task is tracked separately from _tasks because it has a different
-        lifecycle: it runs for the entire lifetime of the VP, not just during service start.
-        """
-        while True:
-            try:
-                if self.tailscale_fqdn:
-                    needs_renewal = tailscale_service.cert_needs_renewal(
-                        self._cert_service.ts_cert_path, fqdn=self.tailscale_fqdn
-                    )
-                    if needs_renewal:
-                        renewed = await self._cert_service.use_tailscale_cert(self.tailscale_fqdn, tailscale_service)
-                        if renewed:
-                            logger.info(
-                                "[VP %s] Tailscale cert renewed for %s, scheduling restart",
-                                self.name,
-                                self.tailscale_fqdn,
-                            )
-                            # Schedule restart in a separate task; this loop ends here
-                            # so the restart can cleanly cancel _cert_renewal_task and
-                            # create a fresh one via start_server/start_proxy.
-                            self._cert_restart_task = asyncio.create_task(
-                                self._restart_for_cert_renewal(),
-                                name=f"vp_{self.id}_cert_restart",
-                            )
-                            break
-                await asyncio.sleep(86400)  # check once per day
-            except asyncio.CancelledError:
-                break
-            except Exception as e:
-                logger.error("[VP %s] Cert renewal loop error: %s", self.name, e)
-                await asyncio.sleep(3600)  # back off 1 h on unexpected error
-
-    async def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]:
+    def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]:
         """Return (cert_path, key_path, advertise_address) for TLS services.
 
-        When Tailscale is available, provisions a LE cert and returns the
-        Tailscale FQDN as the advertise address so SSDP broadcasts the hostname
-        that matches the trusted cert.
-
-        Falls back to the self-signed cert and IP-based advertising when
-        Tailscale is absent or provisioning fails.
+        Always uses the self-signed cert chain (signed by `bbl_ca`). The user
+        imports `bbl_ca.crt` once into the slicer; per-VP certs validate from
+        there. Tailscale exposure is handled by the user picking the Tailscale
+        IP in the bind_ip dropdown.
         """
-        if self.tailscale_disabled:
-            logger.info("[VP %s] Tailscale integration disabled by user, using self-signed cert", self.name)
-        else:
-            try:
-                ts_status = await tailscale_service.get_status()
-                if ts_status.available:
-                    ts_result = await self._cert_service.use_tailscale_cert(ts_status.fqdn, tailscale_service)
-                    if ts_result:
-                        self.tailscale_fqdn = ts_status.fqdn
-                        logger.info("[VP %s] Using Tailscale cert for %s", self.name, ts_status.fqdn)
-                        return ts_result[0], ts_result[1], ts_status.fqdn
-                    logger.warning(
-                        "[VP %s] Tailscale available (%s) but cert provisioning failed, falling back to self-signed cert",
-                        self.name,
-                        ts_status.fqdn,
-                    )
-                else:
-                    logger.info(
-                        "[VP %s] Tailscale not available (%s), using self-signed cert",
-                        self.name,
-                        ts_status.error or "not connected",
-                    )
-            except Exception as e:
-                logger.warning("[VP %s] Tailscale cert check failed, falling back to self-signed: %s", self.name, e)
-
-        self.tailscale_fqdn = None
         cert_path, key_path = self.generate_certificates()
         advertise = self.remote_interface_ip or self.bind_ip or ""
         return cert_path, key_path, advertise
@@ -584,7 +457,7 @@ class VirtualPrinterInstance:
         """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
         logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
 
-        cert_path, key_path, advertise_addr = await self._resolve_cert_and_advertise()
+        cert_path, key_path, advertise_addr = self._resolve_cert_and_advertise()
         bind_addr = self.bind_ip or "0.0.0.0"  # nosec B104
 
         async def run_with_logging(coro, svc_name):
@@ -700,16 +573,10 @@ class VirtualPrinterInstance:
             )
         )
 
-        # Guard against double-start: cancel any orphaned task before creating a new one
-        await self._cancel_renewal_task()
-        self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal")
-
         logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
 
     async def stop_server(self) -> None:
         """Stop server-mode services."""
-        await self._cancel_renewal_task()
-        await self._cancel_restart_task()
         if self._mqtt_bridge:
             try:
                 await self._mqtt_bridge.stop()
@@ -742,7 +609,7 @@ class VirtualPrinterInstance:
         """Start proxy mode services for this instance."""
         logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
 
-        cert_path, key_path, _ = await self._resolve_cert_and_advertise()
+        cert_path, key_path, _ = self._resolve_cert_and_advertise()
 
         self._proxy = SlicerProxyManager(
             target_host=self.target_printer_ip,
@@ -797,10 +664,6 @@ class VirtualPrinterInstance:
             )
         )
 
-        # Guard against double-start: cancel any orphaned task before creating a new one
-        await self._cancel_renewal_task()
-        self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal")
-
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
         """Start single-interface SSDP server as fallback for proxy mode."""
         self._ssdp = VirtualPrinterSSDPServer(
@@ -819,8 +682,6 @@ class VirtualPrinterInstance:
 
     async def stop_proxy(self) -> None:
         """Stop proxy mode services for this instance."""
-        await self._cancel_renewal_task()
-        await self._cancel_restart_task()
         if self._proxy:
             await self._proxy.stop()
             self._proxy = None
@@ -849,8 +710,6 @@ class VirtualPrinterInstance:
             "running": self.is_running,
             "pending_files": len(self._pending_files),
         }
-        if self.tailscale_fqdn:
-            status["tailscale_fqdn"] = self.tailscale_fqdn
         if self.is_proxy and self._proxy:
             status["proxy"] = self._proxy.get_status()
         return status
@@ -947,7 +806,6 @@ class VirtualPrinterManager:
                 or instance.remote_interface_ip != (vp.remote_interface_ip or "")
                 or instance.target_printer_id != vp.target_printer_id
                 or instance.auto_dispatch != vp.auto_dispatch
-                or instance.tailscale_disabled != vp.tailscale_disabled
             )
 
             if changed:

+ 17 - 162
backend/app/services/virtual_printer/tailscale.py

@@ -1,43 +1,28 @@
-"""Tailscale integration for virtual printer certificate provisioning.
-
-When Tailscale is present, provisions a Let's Encrypt certificate via
-`tailscale cert` for the machine's Tailscale FQDN. This cert is trusted
-by slicers without any manual CA installation, unlike the self-signed CA.
-
-Falls back gracefully when Tailscale is unavailable.
+"""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 re
 import shutil
 from dataclasses import dataclass, field
-from datetime import datetime, timezone
 from pathlib import Path
 
-from cryptography import x509
-
 logger = logging.getLogger(__name__)
 
-# Renew when fewer than this many days remain on the LE cert (LE issues 90-day certs;
-# Let's Encrypt recommends renewing at 30 days remaining)
-TS_CERT_EXPIRY_THRESHOLD_DAYS = 30
-
-# Defensive FQDN validation before passing to subprocess
-_FQDN_RE = re.compile(
-    r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$",
-    re.IGNORECASE,
-)
-
-# Detect tailnets where HTTPS cert generation is disabled — common for company/school
-# tailnets where the user is not a Tailscale admin.
-_HTTPS_DISABLED_RE = re.compile(
-    r"(https? cert.*disabled|not enabled.*tailnet|cert.*not.*enabled)",
-    re.IGNORECASE,
-)
-
 # 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.
@@ -82,7 +67,7 @@ class TailscaleStatus:
 
 
 class TailscaleService:
-    """Wraps Tailscale CLI commands for certificate provisioning.
+    """Wraps `tailscale status` for presence detection.
 
     All methods are safe to call when Tailscale is absent — they return
     sensible defaults and never raise exceptions.
@@ -92,21 +77,15 @@ class TailscaleService:
 
     @classmethod
     def _log_docker_socket_hint(cls) -> None:
-        """Log a one-time hint when running in Docker without the Tailscale socket mounted.
-
-        Fires in both states: (a) tailscale binary missing and (b) binary present
-        but the host socket isn't mounted into the container. The binary alone
-        can't talk to the daemon — the host's tailscaled socket needs to be
-        volume-mounted in docker-compose.yml.
-        """
+        """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 enable "
-                "Let's Encrypt certs for virtual printers."
+                "to docker-compose.yml (under volumes:) and run Tailscale on the host to "
+                "expose virtual printers over your tailnet."
             )
             cls._docker_hint_logged = True
 
@@ -114,8 +93,6 @@ class TailscaleService:
         """Run a tailscale subcommand and return (returncode, stdout, stderr).
 
         Resolves the binary to an absolute path to guard against PATH hijacking.
-        Raises OSError if the binary cannot be found or launched.
-        Raises asyncio.TimeoutError if the subprocess exceeds the timeout.
         """
         binary = shutil.which("tailscale")
         if not binary:
@@ -138,8 +115,6 @@ class TailscaleService:
     async def get_status(self) -> TailscaleStatus:
         """Query Tailscale status and return machine identity.
 
-        Runs: tailscale status --json
-
         Returns TailscaleStatus(available=False) if the binary is missing,
         the daemon is not running, or any other error occurs.
         """
@@ -165,9 +140,6 @@ class TailscaleService:
             )
 
         if returncode is None or returncode != 0:
-            # If the binary is present but the daemon socket is unreachable (e.g.
-            # Docker without the socket mount), log the actionable hint rather than
-            # just the opaque CLI stderr.
             self._log_docker_socket_hint()
             return TailscaleStatus(
                 available=False,
@@ -201,7 +173,6 @@ class TailscaleService:
                 error="Tailscale not connected (no DNSName)",
             )
 
-        # Split "myhost.tailnetname.ts.net" into hostname + tailnet_name
         parts = fqdn.split(".", 1)
         hostname = parts[0]
         tailnet_name = parts[1] if len(parts) > 1 else ""
@@ -217,122 +188,6 @@ class TailscaleService:
             tailscale_ips=tailscale_ips,
         )
 
-    async def provision_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
-        """Request a Let's Encrypt certificate for the given Tailscale FQDN.
-
-        Runs: tailscale cert --cert-file <cert_path> --key-file <key_path> <fqdn>
-
-        Returns True on success, False on any error.
-        """
-        if not _FQDN_RE.match(fqdn):
-            logger.warning("provision_cert: invalid FQDN %r, skipping", fqdn)
-            return False
-
-        # Ensure the target directory exists before tailscale cert writes to it
-        cert_path.parent.mkdir(parents=True, exist_ok=True)
-
-        logger.info("Provisioning Tailscale cert for %s -> %s", fqdn, cert_path)
-        try:
-            returncode, _, stderr = await self._run_tailscale(
-                "cert",
-                "--cert-file",
-                str(cert_path),
-                "--key-file",
-                str(key_path),
-                fqdn,
-                timeout=60.0,
-            )
-        except OSError as e:
-            logger.warning("tailscale cert failed (OS error): %s", e)
-            return False
-
-        if returncode is None or returncode != 0:
-            err_text = stderr.decode(errors="replace").strip()
-            if _HTTPS_DISABLED_RE.search(err_text):
-                logger.warning(
-                    "Tailscale HTTPS certs are not enabled for this tailnet. "
-                    "Visit https://login.tailscale.com/admin/dns and enable HTTPS. "
-                    "Falling back to self-signed cert."
-                )
-            else:
-                logger.warning("tailscale cert failed (exit %s): %s", returncode, err_text)
-            return False
-
-        # Restrict private key permissions
-        try:
-            key_path.chmod(0o600)
-        except OSError as e:
-            logger.warning("Could not set key permissions on %s: %s", key_path, e)
-
-        # Verify the files are readable by the current process — on bare-metal, the
-        # tailscale daemon or a prior sudo invocation may have left them root-owned.
-        if not os.access(cert_path, os.R_OK) or not os.access(key_path, os.R_OK):
-            logger.error(
-                "Tailscale cert files at %s are not readable by this process. "
-                "Fix with: sudo chown $(whoami):$(whoami) %s %s",
-                cert_path.parent,
-                cert_path,
-                key_path,
-            )
-            return False
-
-        logger.info("Tailscale cert provisioned: %s", cert_path)
-        return True
-
-    def cert_needs_renewal(self, cert_path: Path, fqdn: str | None = None) -> bool:
-        """Check whether the certificate at cert_path needs to be renewed.
-
-        Returns True if the file is absent, unreadable, expires within
-        TS_CERT_EXPIRY_THRESHOLD_DAYS days, or if fqdn is given and does not
-        appear in the certificate's Subject Alternative Names.
-        """
-        if not cert_path.exists():
-            return True
-
-        try:
-            cert_pem = cert_path.read_bytes()
-            # The file may contain a full chain; load only the first PEM block
-            cert = x509.load_pem_x509_certificate(cert_pem)
-            now = datetime.now(timezone.utc)
-            days_remaining = (cert.not_valid_after_utc - now).days
-            if days_remaining < TS_CERT_EXPIRY_THRESHOLD_DAYS:
-                logger.info("Tailscale cert expires in %d days, renewal needed", days_remaining)
-                return True
-
-            # Validate that the cert covers the requested FQDN (guards against stale
-            # cert after machine rename or tailnet migration). Case-insensitive per RFC 4343.
-            if fqdn:
-                try:
-                    san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
-                    dns_names = san.value.get_values_for_type(x509.DNSName)
-                    if fqdn.lower() not in {n.lower() for n in dns_names}:
-                        logger.info(
-                            "Tailscale cert SAN mismatch (cert has %s, need %s), renewal needed",
-                            dns_names,
-                            fqdn,
-                        )
-                        return True
-                except x509.ExtensionNotFound:
-                    logger.info("Tailscale cert has no SAN extension, renewal needed")
-                    return True
-
-            logger.debug("Tailscale cert valid for %d more days", days_remaining)
-            return False
-        except (OSError, ValueError) as e:
-            logger.warning("Could not read Tailscale cert %s: %s", cert_path, e)
-            return True
-
-    async def ensure_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
-        """Ensure a fresh certificate exists at cert_path.
-
-        Skips provisioning if the cert is present, not near expiry, and covers fqdn.
-        Returns True if a valid cert is now available.
-        """
-        if not self.cert_needs_renewal(cert_path, fqdn=fqdn):
-            logger.debug("Tailscale cert is fresh, skipping provision")
-            return True
-        return await self.provision_cert(fqdn, cert_path, key_path)
-
 
 # Module-level singleton — import this in other modules
 tailscale_service = TailscaleService()

+ 21 - 80
backend/tests/integration/test_virtual_printer_api.py

@@ -340,106 +340,47 @@ class TestVirtualPrinterAutoDispatchAPI:
         assert get_resp.json()["auto_dispatch"] is False
 
 
-class TestVirtualPrinterTailscaleGuardAPI:
-    """Enabling Tailscale on a host without the binary must be rejected with 409."""
+class TestVirtualPrinterTailscaleToggleAPI:
+    """The Tailscale toggle is informational — toggling either way always succeeds.
+
+    There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
+    back when the toggle controlled LE cert provisioning. That path was removed:
+    the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
+    system trust store, so even an LE cert wouldn't be accepted. The toggle now
+    only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
+    irrelevant to whether the toggle can be flipped.
+    """
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_enable_tailscale_rejected_when_binary_missing(self, async_client: AsyncClient):
-        """PUT tailscale_disabled=False must 409 when tailscale_service reports unavailable."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
-
+    async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
+        """PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
         create_resp = await async_client.post(
             "/api/v1/virtual-printers",
             json={
-                "name": "TestTailscaleGuard",
+                "name": "TestTailscaleToggle",
                 "mode": "immediate",
                 "access_code": "12345678",
             },
         )
         assert create_resp.status_code == 200
         vp_id = create_resp.json()["id"]
-        # New VPs default to tailscale_disabled=True (opt-in).
         assert create_resp.json()["tailscale_disabled"] is True
 
-        mock_status = TailscaleStatus(
-            available=False,
-            hostname="",
-            tailnet_name="",
-            fqdn="",
-            error="tailscale binary not found",
-        )
         with patch(
             "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
-            new=AsyncMock(return_value=mock_status),
+            new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
         ):
-            update_resp = await async_client.put(
+            enable_resp = await async_client.put(
                 f"/api/v1/virtual-printers/{vp_id}",
                 json={"tailscale_disabled": False},
             )
-        assert update_resp.status_code == 409
-        assert update_resp.json()["detail"] == "tailscale_not_available"
-
-        # Ensure the row was not modified.
-        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
-        assert get_resp.json()["tailscale_disabled"] is True
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_enable_tailscale_allowed_when_binary_present(self, async_client: AsyncClient):
-        """PUT tailscale_disabled=False succeeds when tailscale_service reports available."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
-
-        create_resp = await async_client.post(
-            "/api/v1/virtual-printers",
-            json={
-                "name": "TestTailscaleAllow",
-                "mode": "immediate",
-                "access_code": "12345678",
-            },
-        )
-        vp_id = create_resp.json()["id"]
-
-        mock_status = TailscaleStatus(
-            available=True,
-            hostname="host",
-            tailnet_name="tail.ts.net",
-            fqdn="host.tail.ts.net",
-            error=None,
-        )
-        with patch(
-            "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
-            new=AsyncMock(return_value=mock_status),
-        ):
-            update_resp = await async_client.put(
-                f"/api/v1/virtual-printers/{vp_id}",
-                json={"tailscale_disabled": False},
-            )
-        assert update_resp.status_code == 200
-        assert update_resp.json()["tailscale_disabled"] is False
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_disable_tailscale_skips_binary_check(self, async_client: AsyncClient):
-        """Disabling (tailscale_disabled=True) never calls tailscale_service — always allowed."""
-        create_resp = await async_client.post(
-            "/api/v1/virtual-printers",
-            json={
-                "name": "TestTailscaleDisableSkip",
-                "mode": "immediate",
-                "access_code": "12345678",
-            },
-        )
-        vp_id = create_resp.json()["id"]
-
-        # get_status must not be called on the disable path.
-        with patch(
-            "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
-            new=AsyncMock(side_effect=AssertionError("get_status must not be called for disable")),
-        ):
-            update_resp = await async_client.put(
+            disable_resp = await async_client.put(
                 f"/api/v1/virtual-printers/{vp_id}",
                 json={"tailscale_disabled": True},
             )
-        assert update_resp.status_code == 200
-        assert update_resp.json()["tailscale_disabled"] is True
+
+        assert enable_resp.status_code == 200
+        assert enable_resp.json()["tailscale_disabled"] is False
+        assert disable_resp.status_code == 200
+        assert disable_resp.json()["tailscale_disabled"] is True

+ 22 - 748
backend/tests/unit/services/test_tailscale.py

@@ -1,54 +1,19 @@
-"""Unit tests for TailscaleService and Tailscale-aware VirtualPrinterInstance."""
+"""Unit tests for TailscaleService — presence detection only.
+
+Cert provisioning was removed: BambuStudio's printer-MQTT trust path validates
+against its bundled BBL CA, not the system trust store, so a Tailscale-issued
+LE cert was rejected regardless of hostname/IP. The Tailscale toggle is now
+informational (surfacing the host's Tailscale IP/FQDN to guide the user).
+"""
 
-import asyncio
 import json
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, patch
 
 import pytest
-from cryptography import x509
-from cryptography.hazmat.primitives import hashes, serialization
-from cryptography.hazmat.primitives.asymmetric import rsa
-from cryptography.x509.oid import NameOID
-
-
-def _make_cert(tmp_path: Path, days_valid: int, fqdn: str | None = None) -> Path:
-    """Write a self-signed cert valid for days_valid days and return its path.
-
-    If fqdn is provided the cert includes a SubjectAlternativeName DNS entry.
-    """
-    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
-    now = datetime.now(timezone.utc)
-    builder = (
-        x509.CertificateBuilder()
-        .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]))
-        .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]))
-        .public_key(key.public_key())
-        .serial_number(x509.random_serial_number())
-        .not_valid_before(now)
-        .not_valid_after(now + timedelta(days=days_valid))
-    )
-    if fqdn:
-        builder = builder.add_extension(
-            x509.SubjectAlternativeName([x509.DNSName(fqdn)]),
-            critical=False,
-        )
-    cert = builder.sign(key, hashes.SHA256())
-    path = tmp_path / "cert.crt"
-    path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
-    return path
-
-
-# =============================================================================
-# TailscaleService tests
-# =============================================================================
 
 
 class TestTailscaleService:
-    """Tests for TailscaleService CLI wrapper."""
-
-    # -- get_status --
+    """Tests for TailscaleService CLI wrapper — get_status only."""
 
     @pytest.mark.asyncio
     async def test_get_status_binary_not_found(self):
@@ -65,7 +30,7 @@ class TestTailscaleService:
 
     @pytest.mark.asyncio
     async def test_get_status_command_fails(self):
-        """Returns available=False when the tailscale status command exits non-zero."""
+        """Returns available=False when `tailscale status` exits non-zero."""
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
         svc = TailscaleService()
@@ -104,726 +69,35 @@ class TestTailscaleService:
         assert status.tailnet_name == "example.ts.net"
         assert "100.1.2.3" in status.tailscale_ips
 
-    # -- provision_cert --
-
-    @pytest.mark.asyncio
-    async def test_provision_cert_success(self, tmp_path):
-        """Returns True and forwards the correct arguments to _run_tailscale."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = tmp_path / "ts.crt"
-        key_path = tmp_path / "ts.key"
-        cert_path.write_text("fake-cert")
-        key_path.write_text("fake-key")
-
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
-            result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
-
-        assert result is True
-        called_args = mock_run.call_args[0]  # positional args to _run_tailscale
-        assert "cert" in called_args
-        assert "--cert-file" in called_args
-        assert str(cert_path) in called_args
-        assert "myhost.ts.net" in called_args
-
-    @pytest.mark.asyncio
-    async def test_provision_cert_failure(self, tmp_path):
-        """Returns False without raising when the tailscale cert command fails."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"not logged in")):
-            result = await svc.provision_cert("myhost.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
-
-        assert result is False
-
-    # -- cert_needs_renewal --
-
-    def test_cert_needs_renewal_absent(self, tmp_path):
-        """Returns True when the cert file does not exist."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(tmp_path / "nonexistent.crt") is True
-
-    def test_cert_needs_renewal_fresh(self, tmp_path):
-        """Returns False when the cert has more than the threshold days remaining."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60)
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path) is False
-
-    def test_cert_needs_renewal_expiring(self, tmp_path):
-        """Returns True when the cert is within the renewal threshold."""
-        from backend.app.services.virtual_printer.tailscale import (
-            TS_CERT_EXPIRY_THRESHOLD_DAYS,
-            TailscaleService,
-        )
-
-        cert_path = _make_cert(tmp_path, days_valid=TS_CERT_EXPIRY_THRESHOLD_DAYS - 1)
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path) is True
-
-    # -- ensure_cert --
-
-    @pytest.mark.asyncio
-    async def test_ensure_cert_skips_provision_when_fresh(self, tmp_path):
-        """Does not call provision_cert when the existing cert is still fresh."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with (
-            patch.object(svc, "cert_needs_renewal", return_value=False),
-            patch.object(svc, "provision_cert", new_callable=AsyncMock) as mock_prov,
-        ):
-            result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
-
-        assert result is True
-        mock_prov.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_ensure_cert_provisions_when_absent(self, tmp_path):
-        """Calls provision_cert when no valid cert exists."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with (
-            patch.object(svc, "cert_needs_renewal", return_value=True),
-            patch.object(svc, "provision_cert", new_callable=AsyncMock, return_value=True) as mock_prov,
-        ):
-            result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
-
-        assert result is True
-        mock_prov.assert_called_once()
-
-
-# =============================================================================
-# VirtualPrinterInstance Tailscale integration tests
-# =============================================================================
-
-
-class TestVirtualPrinterInstanceTailscale:
-    """Tests for Tailscale cert/advertise resolution in VirtualPrinterInstance."""
-
-    @pytest.fixture
-    def instance(self, tmp_path):
-        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
-
-        # Tailscale is opt-in (default True); tests in this class exercise the enabled
-        # path, so explicitly opt in.
-        return VirtualPrinterInstance(
-            vp_id=1,
-            name="TestPrinter",
-            mode="immediate",
-            model="C11",
-            access_code="12345678",
-            serial_suffix="391800001",
-            tailscale_disabled=False,
-            base_dir=tmp_path,
-        )
-
-    @pytest.mark.asyncio
-    async def test_resolve_uses_tailscale_when_available(self, instance):
-        """Returns TS cert paths and FQDN advertise address when Tailscale is up."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
-
-        ts_cert = instance.cert_dir / "virtual_printer_ts.crt"
-        ts_key = instance.cert_dir / "virtual_printer_ts.key"
-
-        mock_ts = MagicMock()
-        mock_ts.get_status = AsyncMock(
-            return_value=TailscaleStatus(
-                available=True,
-                hostname="myhost",
-                tailnet_name="example.ts.net",
-                fqdn="myhost.example.ts.net",
-                tailscale_ips=["100.1.2.3"],
-            )
-        )
-
-        with (
-            patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
-            patch.object(
-                instance._cert_service,
-                "use_tailscale_cert",
-                new_callable=AsyncMock,
-                return_value=(ts_cert, ts_key),
-            ),
-        ):
-            cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
-
-        assert cert_path == ts_cert
-        assert key_path == ts_key
-        assert advertise == "myhost.example.ts.net"
-        assert instance.tailscale_fqdn == "myhost.example.ts.net"
-
-    @pytest.mark.asyncio
-    async def test_resolve_falls_back_to_selfsigned(self, instance, tmp_path):
-        """Falls back to self-signed cert and IP string when Tailscale is absent."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
-
-        self_cert = tmp_path / "cert.crt"
-        self_key = tmp_path / "cert.key"
-
-        mock_ts = MagicMock()
-        mock_ts.get_status = AsyncMock(
-            return_value=TailscaleStatus(
-                available=False,
-                hostname="",
-                tailnet_name="",
-                fqdn="",
-                error="tailscale binary not found",
-            )
-        )
-
-        with (
-            patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
-            patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
-        ):
-            cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
-
-        assert cert_path == self_cert
-        assert key_path == self_key
-        assert instance.tailscale_fqdn is None
-        assert isinstance(advertise, str)
-
-    def test_tailscale_fqdn_in_status_when_set(self, instance):
-        """get_status() includes tailscale_fqdn when it is set."""
-        instance.tailscale_fqdn = "myhost.example.ts.net"
-        status = instance.get_status()
-        assert status.get("tailscale_fqdn") == "myhost.example.ts.net"
-
-    def test_tailscale_fqdn_absent_from_status_when_none(self, instance):
-        """get_status() omits the tailscale_fqdn key when tailscale_fqdn is None."""
-        instance.tailscale_fqdn = None
-        status = instance.get_status()
-        assert "tailscale_fqdn" not in status
-
-    @pytest.mark.asyncio
-    async def test_tailscale_disabled_skips_tailscale_entirely(self, tmp_path):
-        """When tailscale_disabled=True, Tailscale is never queried and self-signed cert is used."""
-        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
-
-        self_cert = tmp_path / "cert.crt"
-        self_key = tmp_path / "cert.key"
-
-        instance = VirtualPrinterInstance(
-            vp_id=2,
-            name="NoTailscale",
-            mode="immediate",
-            model="C11",
-            access_code="12345678",
-            serial_suffix="391800001",
-            tailscale_disabled=True,
-            base_dir=tmp_path,
-        )
-
-        mock_ts = MagicMock()
-        mock_ts.get_status = AsyncMock()
-
-        with (
-            patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
-            patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
-        ):
-            cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
-
-        # Tailscale must never have been queried
-        mock_ts.get_status.assert_not_called()
-        assert cert_path == self_cert
-        assert key_path == self_key
-        assert instance.tailscale_fqdn is None
-
-    @pytest.mark.asyncio
-    async def test_tailscale_enabled_explicitly_queries_tailscale(self, instance):
-        """When tailscale_disabled=False (user opted in), Tailscale is queried as usual."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
-
-        mock_ts = MagicMock()
-        mock_ts.get_status = AsyncMock(
-            return_value=TailscaleStatus(
-                available=False,
-                hostname="",
-                tailnet_name="",
-                fqdn="",
-                error="not connected",
-            )
-        )
-
-        self_cert = instance.cert_dir / "cert.crt"
-        self_key = instance.cert_dir / "cert.key"
-
-        with (
-            patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
-            patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
-        ):
-            await instance._resolve_cert_and_advertise()
-
-        mock_ts.get_status.assert_called_once()
-
-
-# =============================================================================
-# cert_needs_renewal — FQDN SAN validation, exception narrowing, FQDN regex
-# =============================================================================
-
-
-class TestCertNeedsRenewalExtended:
-    """Extended tests for cert_needs_renewal() covering new FQDN and exception logic."""
-
-    def test_fqdn_match_fresh_cert_not_renewed(self, tmp_path):
-        """Fresh cert whose SAN matches the requested FQDN is not renewed."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        fqdn = "myhost.example.ts.net"
-        cert_path = _make_cert(tmp_path, days_valid=60, fqdn=fqdn)
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path, fqdn=fqdn) is False
-
-    def test_fqdn_mismatch_triggers_renewal(self, tmp_path):
-        """Fresh cert whose SAN does NOT match the requested FQDN triggers renewal."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60, fqdn="oldhost.example.ts.net")
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path, fqdn="newhost.example.ts.net") is True
-
-    def test_cert_without_san_triggers_renewal_when_fqdn_given(self, tmp_path):
-        """Cert with no SAN extension triggers renewal when an FQDN is requested."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path, fqdn="myhost.example.ts.net") is True
-
-    def test_fqdn_not_checked_when_none(self, tmp_path):
-        """Fresh cert with no SAN is valid when no FQDN is requested (backward-compat)."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path, fqdn=None) is False
-
-    def test_narrow_exception_oserror_triggers_renewal(self, tmp_path):
-        """OSError while reading the cert file triggers renewal."""
-        from unittest.mock import patch
-
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60)
-        svc = TailscaleService()
-        with patch("pathlib.Path.read_bytes", side_effect=OSError("permission denied")):
-            assert svc.cert_needs_renewal(cert_path) is True
-
-    def test_narrow_exception_valueerror_triggers_renewal(self, tmp_path):
-        """ValueError (bad PEM data) while loading the cert triggers renewal."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = tmp_path / "bad.crt"
-        cert_path.write_bytes(b"not a valid pem")
-        svc = TailscaleService()
-        assert svc.cert_needs_renewal(cert_path) is True
-
-    def test_programming_error_propagates(self, tmp_path):
-        """Unexpected exceptions (not OSError/ValueError) are NOT silently swallowed."""
-        from unittest.mock import patch
-
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        cert_path = _make_cert(tmp_path, days_valid=60)
-        svc = TailscaleService()
-        with (
-            patch("pathlib.Path.read_bytes", side_effect=RuntimeError("unexpected")),
-            pytest.raises(RuntimeError, match="unexpected"),
-        ):
-            svc.cert_needs_renewal(cert_path)
-
-
-class TestProvisionCertFQDNValidation:
-    """Tests for FQDN input validation in provision_cert()."""
-
-    @pytest.mark.asyncio
-    async def test_invalid_fqdn_rejected_without_subprocess(self, tmp_path):
-        """provision_cert() returns False immediately for an invalid FQDN."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
-            result = await svc.provision_cert("../evil", tmp_path / "c.crt", tmp_path / "k.key")
-
-        assert result is False
-        mock_run.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_single_label_fqdn_rejected(self, tmp_path):
-        """A hostname without dots (no tailnet) is rejected."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
-            result = await svc.provision_cert("justhostname", tmp_path / "c.crt", tmp_path / "k.key")
-
-        assert result is False
-        mock_run.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_valid_fqdn_passes_to_subprocess(self, tmp_path):
-        """A valid FQDN is forwarded to _run_tailscale."""
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        key_path = tmp_path / "k.key"
-        cert_path = tmp_path / "c.crt"
-        cert_path.write_text("fake-cert")
-        key_path.write_text("fake")
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
-            result = await svc.provision_cert("myhost.example.ts.net", cert_path, key_path)
-
-        assert result is True
-        assert "myhost.example.ts.net" in mock_run.call_args[0]
-
-
-# =============================================================================
-# Additional coverage: OSError path, JSON error, CertificateService wrapper
-# =============================================================================
-
-
-class TestProvisionCertOSError:
-    """provision_cert returns False when _run_tailscale raises OSError."""
-
-    @pytest.mark.asyncio
-    async def test_oserror_returns_false(self, tmp_path):
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, side_effect=OSError("no binary")):
-            result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
-
-        assert result is False
-
-
-class TestProvisionCertHTTPSDisabled:
-    """provision_cert logs an actionable message when the tailnet has HTTPS certs disabled."""
-
-    @pytest.mark.asyncio
-    async def test_https_disabled_logs_admin_url(self, tmp_path, caplog):
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        disabled_stderr = b"HTTPS cert generation is disabled for this tailnet"
-        with (
-            patch.object(
-                svc,
-                "_run_tailscale",
-                new_callable=AsyncMock,
-                return_value=(1, b"", disabled_stderr),
-            ),
-            caplog.at_level("WARNING"),
-        ):
-            result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
-
-        assert result is False
-        assert "login.tailscale.com/admin/dns" in caplog.text
-
-    @pytest.mark.asyncio
-    async def test_generic_error_logs_exit_code(self, tmp_path, caplog):
-        from backend.app.services.virtual_printer.tailscale import TailscaleService
-
-        svc = TailscaleService()
-        with (
-            patch.object(
-                svc,
-                "_run_tailscale",
-                new_callable=AsyncMock,
-                return_value=(1, b"", b"some other error"),
-            ),
-            caplog.at_level("WARNING"),
-        ):
-            result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
-
-        assert result is False
-        assert "exit 1" in caplog.text
-        assert "login.tailscale.com" not in caplog.text
-
-
-class TestProvisionCertReadability:
-    """provision_cert returns False when cert files are not readable after provisioning."""
-
     @pytest.mark.asyncio
-    async def test_unreadable_key_returns_false(self, tmp_path, caplog):
+    async def test_get_status_empty_dnsname(self):
+        """Returns available=False when Tailscale daemon reports no DNSName (not connected)."""
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
+        payload = {"Self": {"DNSName": "", "TailscaleIPs": []}}
         svc = TailscaleService()
-        cert_path = tmp_path / "c.crt"
-        key_path = tmp_path / "k.key"
         with (
+            patch("shutil.which", return_value="/usr/bin/tailscale"),
             patch.object(
-                svc,
-                "_run_tailscale",
-                new_callable=AsyncMock,
-                return_value=(0, b"", b""),
+                svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
             ),
-            patch("os.access", return_value=False),
-            caplog.at_level("ERROR"),
         ):
-            result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
-
-        assert result is False
-        assert "not readable" in caplog.text
-        assert "chown" in caplog.text
-
+            status = await svc.get_status()
 
-class TestGetStatusJSONError:
-    """get_status returns available=False when tailscale outputs non-JSON."""
+        assert status.available is False
+        assert "no DNSName" in (status.error or "")
 
     @pytest.mark.asyncio
-    async def test_bad_json_returns_unavailable(self):
+    async def test_get_status_malformed_json(self):
+        """Returns available=False with a parse-error reason when stdout is not JSON."""
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
         svc = TailscaleService()
         with (
             patch("shutil.which", return_value="/usr/bin/tailscale"),
-            patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not json {{", b"")),
+            patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not-json{", b"")),
         ):
             status = await svc.get_status()
 
         assert status.available is False
-        assert status.error is not None
-        assert "JSON" in status.error
-
-
-class TestUseTailscaleCertWrapper:
-    """CertificateService.use_tailscale_cert delegates to tailscale_svc.ensure_cert."""
-
-    @pytest.mark.asyncio
-    async def test_returns_paths_on_success(self, tmp_path):
-        from backend.app.services.virtual_printer.certificate import CertificateService
-
-        svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
-        mock_ts = MagicMock()
-        mock_ts.ensure_cert = AsyncMock(return_value=True)
-
-        result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
-
-        assert result == (svc.ts_cert_path, svc.ts_key_path)
-        mock_ts.ensure_cert.assert_called_once_with("myhost.ts.net", svc.ts_cert_path, svc.ts_key_path)
-
-    @pytest.mark.asyncio
-    async def test_returns_none_on_failure(self, tmp_path):
-        from backend.app.services.virtual_printer.certificate import CertificateService
-
-        svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
-        mock_ts = MagicMock()
-        mock_ts.ensure_cert = AsyncMock(return_value=False)
-
-        result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
-
-        assert result is None
-
-
-# =============================================================================
-# _cert_renewal_loop tests
-# =============================================================================
-
-
-class TestCertRenewalLoop:
-    """Tests for VirtualPrinterInstance._cert_renewal_loop."""
-
-    @pytest.fixture
-    def instance(self, tmp_path):
-        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
-
-        return VirtualPrinterInstance(
-            vp_id=99,
-            name="RenewalTestPrinter",
-            mode="immediate",
-            model="C11",
-            access_code="12345678",
-            serial_suffix="391800001",
-            base_dir=tmp_path,
-        )
-
-    @pytest.mark.asyncio
-    async def test_loop_skips_when_fqdn_not_set(self, instance):
-        """Loop does nothing when tailscale_fqdn is None — just sleeps."""
-        instance.tailscale_fqdn = None
-        sleep_call_count = [0]
-
-        async def fast_sleep(n):
-            sleep_call_count[0] += 1
-            if sleep_call_count[0] >= 2:
-                raise asyncio.CancelledError()
-
-        with (
-            patch("asyncio.sleep", side_effect=fast_sleep),
-            patch.object(instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock) as mock_use,
-        ):
-            task = asyncio.create_task(instance._cert_renewal_loop())
-            try:
-                await task
-            except asyncio.CancelledError:
-                pass
-
-        mock_use.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_loop_calls_renewal_when_cert_needs_it(self, instance):
-        """Loop calls use_tailscale_cert when fqdn is set and cert needs renewal."""
-        instance.tailscale_fqdn = "myhost.ts.net"
-
-        async def fast_sleep(n):
-            raise asyncio.CancelledError()
-
-        with (
-            patch("asyncio.sleep", side_effect=fast_sleep),
-            patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
-            patch.object(
-                instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock, return_value=None
-            ) as mock_use,
-        ):
-            mock_ts.cert_needs_renewal.return_value = True
-            task = asyncio.create_task(instance._cert_renewal_loop())
-            try:
-                await task
-            except asyncio.CancelledError:
-                pass
-
-        mock_use.assert_called_once()
-
-    @pytest.mark.asyncio
-    async def test_loop_cancelled_error_exits_cleanly(self, instance):
-        """CancelledError in the sleep breaks the loop without raising."""
-        instance.tailscale_fqdn = None
-
-        async def immediate_cancel(n):
-            raise asyncio.CancelledError()
-
-        with patch("asyncio.sleep", side_effect=immediate_cancel):
-            task = asyncio.create_task(instance._cert_renewal_loop())
-            await task  # must complete without raising
-
-    @pytest.mark.asyncio
-    async def test_loop_backs_off_on_unexpected_error(self, instance):
-        """Unexpected exceptions are logged and the loop backs off with a 3600 s sleep."""
-        instance.tailscale_fqdn = "myhost.ts.net"
-        sleep_args: list[float] = []
-
-        async def tracking_sleep(n):
-            sleep_args.append(n)
-            if len(sleep_args) >= 2:
-                raise asyncio.CancelledError()
-
-        with (
-            patch("asyncio.sleep", side_effect=tracking_sleep),
-            patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
-        ):
-            mock_ts.cert_needs_renewal.side_effect = RuntimeError("unexpected db error")
-            task = asyncio.create_task(instance._cert_renewal_loop())
-            try:
-                await task
-            except asyncio.CancelledError:
-                pass
-
-        assert 3600 in sleep_args
-
-    @pytest.mark.asyncio
-    async def test_loop_schedules_restart_after_renewal(self, instance):
-        """When a renewal succeeds, a restart task is scheduled and the loop exits."""
-        instance.tailscale_fqdn = "myhost.ts.net"
-        restart_scheduled = [False]
-        _real_create_task = asyncio.create_task
-
-        def tracking_create_task(coro, *, name=None):
-            if name and "cert_restart" in name:
-                restart_scheduled[0] = True
-                coro.close()
-                # Return a dummy completed task
-                fut = asyncio.get_event_loop().create_future()
-                fut.set_result(None)
-                return fut
-            return _real_create_task(coro, name=name)
-
-        with (
-            patch("asyncio.sleep", new_callable=AsyncMock),
-            patch.object(asyncio, "create_task", side_effect=tracking_create_task),
-            patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
-            patch.object(
-                instance._cert_service,
-                "use_tailscale_cert",
-                new_callable=AsyncMock,
-                return_value=(instance._cert_service.ts_cert_path, instance._cert_service.ts_key_path),
-            ),
-        ):
-            mock_ts.cert_needs_renewal.return_value = True
-            # Run the loop directly; it exits via break after scheduling the restart
-            task = _real_create_task(instance._cert_renewal_loop())
-            await task
-
-        assert restart_scheduled[0] is True
-
-
-class TestCancelRestartTaskSelfAwait:
-    """Regression: _cancel_restart_task must not await the current task.
-
-    stop_server() / stop_proxy() are called from inside _restart_for_cert_renewal,
-    which runs AS _cert_restart_task. Cancelling+awaiting self would flag a
-    CancelledError on the next `await`, tearing down the old listeners but
-    never letting start_server run — the VP would stay on the old/expired cert
-    until the process restarts.
-    """
-
-    def _make_instance(self, tmp_path):
-        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
-
-        return VirtualPrinterInstance(
-            vp_id=1,
-            name="TestVP",
-            mode="immediate",
-            model="C11",
-            access_code="12345678",
-            serial_suffix="391800001",
-            tailscale_disabled=False,
-            base_dir=tmp_path,
-        )
-
-    @pytest.mark.asyncio
-    async def test_cancel_from_inside_own_task_does_not_cancel_self(self, tmp_path):
-        """When _cancel_restart_task is called from inside the restart task itself,
-        it clears the reference without cancelling — subsequent awaits must succeed."""
-        instance = self._make_instance(tmp_path)
-        completed_to_end = [False]
-
-        async def fake_restart():
-            # Simulate stop_server calling _cancel_restart_task from inside the restart task.
-            await instance._cancel_restart_task()
-            # If _cancel_restart_task had self-awaited, the next `await` would raise
-            # CancelledError and this line would never be reached.
-            await asyncio.sleep(0)
-            completed_to_end[0] = True
-
-        task = asyncio.create_task(fake_restart(), name="cert_restart")
-        instance._cert_restart_task = task
-        await task
-        assert completed_to_end[0] is True
-        assert instance._cert_restart_task is None
-
-    @pytest.mark.asyncio
-    async def test_cancel_from_outside_still_cancels_and_awaits(self, tmp_path):
-        """Non-self callers must retain the original cancel-and-await behaviour."""
-        instance = self._make_instance(tmp_path)
-        started = asyncio.Event()
-
-        async def long_restart():
-            started.set()
-            try:
-                await asyncio.sleep(10)
-            except asyncio.CancelledError:
-                raise
-
-        task = asyncio.create_task(long_restart(), name="cert_restart")
-        instance._cert_restart_task = task
-        await started.wait()
-        # Cancel from an outside coroutine — this should actually cancel the task.
-        await instance._cancel_restart_task()
-        assert task.cancelled()
-        assert instance._cert_restart_task is None
+        assert "JSON parse error" in (status.error or "")

+ 9 - 9
backend/tests/unit/services/test_virtual_printer.py

@@ -926,8 +926,13 @@ class TestVirtualPrinterManager:
             mock_remove.assert_called_once_with(1)
 
     @pytest.mark.asyncio
-    async def test_sync_from_db_restarts_on_tailscale_disabled_change(self, manager, tmp_path):
-        """VP restarts when tailscale_disabled flips from False to True."""
+    async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path):
+        """Flipping tailscale_disabled is purely informational — must NOT trigger a restart.
+
+        Cert provisioning was removed; the toggle only governs whether the VP card surfaces
+        the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing
+        it through sync_from_db should leave any running instance untouched.
+        """
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
         inst = VirtualPrinterInstance(
@@ -947,14 +952,9 @@ class TestVirtualPrinterManager:
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
 
         with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
-            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
-                mock_new = MagicMock()
-                mock_new.start_server = AsyncMock()
-                MockInst.return_value = mock_new
-
-                await manager.sync_from_db()
+            await manager.sync_from_db()
 
-            mock_remove.assert_called_once_with(1)
+        mock_remove.assert_not_called()
 
 
 class TestFTPSession:

+ 39 - 45
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -19,6 +19,14 @@ vi.mock('../../api/client', () => ({
   multiVirtualPrinterApi: {
     update: vi.fn().mockResolvedValue({}),
     remove: vi.fn().mockResolvedValue({}),
+    getTailscaleStatus: vi.fn().mockResolvedValue({
+      available: false,
+      fqdn: '',
+      hostname: '',
+      tailnet_name: '',
+      tailscale_ips: [],
+      error: null,
+    }),
   },
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
@@ -295,46 +303,27 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
     });
   });
 
-  it('reverts toggle and shows a specific toast when backend rejects enable (tailscale_not_available)', async () => {
-    const user = userEvent.setup();
-    const printer = createMockPrinter({ tailscale_disabled: true });
-    vi.mocked(multiVirtualPrinterApi.update).mockRejectedValueOnce(
-      new Error('tailscale_not_available')
-    );
-
-    render(<VirtualPrinterCard printer={printer} models={models} />);
-
-    await waitFor(() => {
-      expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
-    });
-
-    const title = screen.getByText('Tailscale integration');
-    const section = title.closest('.flex.items-center.justify-between');
-    const toggleButton = section!.querySelector('button') as HTMLButtonElement;
-    // Disabled state → dark-grey background on the track.
-    expect(toggleButton.className).toContain('bg-bambu-dark-tertiary');
-
-    await user.click(toggleButton);
-
-    await waitFor(() => {
-      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: false });
-    });
-
-    // After the 409 revert, the toggle goes back to the dark-grey (disabled) state.
-    await waitFor(() => {
-      expect(toggleButton.className).toContain('bg-bambu-dark-tertiary');
-    });
-  });
 });
 
 describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
+  const fqdn = 'test-host.tail1234.ts.net';
+
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+    // FQDN now comes from the host-level Tailscale status endpoint, not VP status.
+    // Tests in this block need the toggle to be ON (tailscale_disabled=false) so the
+    // useQuery actually fires and the FQDN row renders.
+    vi.mocked(multiVirtualPrinterApi.getTailscaleStatus).mockResolvedValue({
+      available: true,
+      fqdn,
+      hostname: 'test-host',
+      tailnet_name: 'tail1234.ts.net',
+      tailscale_ips: ['100.64.0.1'],
+      error: null,
+    });
   });
 
-  const fqdn = 'test-host.tail1234.ts.net';
-
   function getCopyButton() {
     // The copy button is a <button> with a title attribute. Use title to locate it.
     const candidates = screen.getAllByRole('button');
@@ -351,13 +340,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
       configurable: true,
     });
 
-    const printer = createMockPrinter({
-      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
-    });
+    const printer = createMockPrinter({ tailscale_disabled: false });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
-    const copyBtn = getCopyButton();
-    expect(copyBtn).toBeTruthy();
+    const copyBtn = await waitFor(() => {
+      const btn = getCopyButton();
+      if (!btn) throw new Error('copy button not yet rendered');
+      return btn;
+    });
     await user.click(copyBtn);
 
     await waitFor(() => {
@@ -374,12 +364,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     const execCommandMock = vi.fn().mockReturnValue(true);
     document.execCommand = execCommandMock;
 
-    const printer = createMockPrinter({
-      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
-    });
+    const printer = createMockPrinter({ tailscale_disabled: false });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
-    const copyBtn = getCopyButton();
+    const copyBtn = await waitFor(() => {
+      const btn = getCopyButton();
+      if (!btn) throw new Error('copy button not yet rendered');
+      return btn;
+    });
     await user.click(copyBtn);
 
     await waitFor(() => {
@@ -399,12 +391,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
       throw new Error('synthetic execCommand failure');
     });
 
-    const printer = createMockPrinter({
-      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
-    });
+    const printer = createMockPrinter({ tailscale_disabled: false });
     render(<VirtualPrinterCard printer={printer} models={models} />);
 
-    const copyBtn = getCopyButton();
+    const copyBtn = await waitFor(() => {
+      const btn = getCopyButton();
+      if (!btn) throw new Error('copy button not yet rendered');
+      return btn;
+    });
     await user.click(copyBtn);
 
     // The `finally` block must remove the textarea regardless of the exception.

+ 1 - 2
frontend/src/api/client.ts

@@ -5645,7 +5645,6 @@ export interface VirtualPrinterStatus {
   pending_files: number;
   target_printer_ip?: string;  // For proxy mode
   proxy?: VirtualPrinterProxyStatus;  // For proxy mode
-  tailscale_fqdn?: string;  // Set when Tailscale cert is active
 }
 
 export interface VirtualPrinterSettings {
@@ -5740,7 +5739,7 @@ export interface VirtualPrinterConfig {
   bind_ip: string | null;
   remote_interface_ip: string | null;
   position: number;
-  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus; tailscale_fqdn?: string };
+  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
 }
 
 export interface VirtualPrinterListResponse {

+ 20 - 11
frontend/src/components/VirtualPrinterCard.tsx

@@ -50,9 +50,21 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [fqdnCopied, setFqdnCopied] = useState(false);
 
+  // Host-level Tailscale identity (same for every VP) — shown inline on the card when
+  // the user has marked this VP as "exposed over Tailscale". Cert handling does NOT
+  // depend on this toggle; the slicer trusts the bambuddy CA the user imports once.
+  const { data: tailscaleStatus } = useQuery({
+    queryKey: ['tailscale-status'],
+    queryFn: multiVirtualPrinterApi.getTailscaleStatus,
+    enabled: !localTailscaleDisabled,
+    staleTime: 60_000,
+  });
+  const tailscaleFqdn = tailscaleStatus?.available ? tailscaleStatus.fqdn : '';
+  const tailscaleIp = tailscaleStatus?.available ? tailscaleStatus.tailscale_ips?.[0] ?? '' : '';
+
   const handleCopyFqdn = async (e: React.MouseEvent) => {
     e.stopPropagation();
-    const fqdn = printer.status?.tailscale_fqdn;
+    const fqdn = tailscaleFqdn;
     if (!fqdn) return;
     let ok = false;
     // Modern API — only works in secure contexts (HTTPS / localhost).
@@ -126,13 +138,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setPendingAction(null);
     },
     onError: (error: Error) => {
-      // Specific: the backend rejected "enable Tailscale" because the binary isn't installed.
-      // Surface a clear reason instead of the raw error code.
-      if (error.message === 'tailscale_not_available') {
-        showToast(t('virtualPrinter.toast.tailscaleNotAvailable'), 'error');
-      } else {
-        showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
-      }
+      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       setLocalEnabled(printer.enabled);
       setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
       setLocalTargetPrinterId(printer.target_printer_id);
@@ -301,12 +307,15 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </button>
             </div>
 
-            {/* Tailscale FQDN (when active) + serial — compact info row */}
+            {/* Tailscale identity (host-level) + serial — compact info row.
+                Shown only when this VP is marked Tailscale-exposed AND the daemon is up. */}
             <div className="flex items-center gap-2 -mt-2">
-              {printer.status?.tailscale_fqdn && (
+              {tailscaleFqdn && (
                 <span className="flex items-center gap-1 text-green-400/70 min-w-0">
                   <ShieldCheck className="w-3.5 h-3.5 flex-shrink-0" />
-                  <span className="font-mono text-xs truncate">{printer.status.tailscale_fqdn}</span>
+                  <span className="font-mono text-xs truncate">
+                    {tailscaleIp ? `${tailscaleIp} (${tailscaleFqdn})` : tailscaleFqdn}
+                  </span>
                   <button
                     onClick={handleCopyFqdn}
                     className="p-0.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"

+ 1 - 2
frontend/src/i18n/locales/de.ts

@@ -4086,7 +4086,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Tailscale-Integration',
-      description: 'Wenn aktiviert, werden Tailscale-zertifizierte TLS-Zertifikate verwendet. Deaktivieren für selbstsignierte Zertifikate.',
+      description: 'Aktivieren, um diesen VP als per Tailscale erreichbar zu markieren. Zeigt die Tailscale-Adresse des Hosts an, damit du weißt, welche IP du im Slicer eintragen musst. Der CA-Import bleibt unverändert — diese Option hat keinen Einfluss auf Zertifikate.',
     },
     setupRequired: {
       title: 'Einrichtung erforderlich',
@@ -4121,7 +4121,6 @@ export default {
     toast: {
       updated: 'Virtuelle Druckereinstellungen aktualisiert',
       failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
-      tailscaleNotAvailable: 'Tailscale ist auf diesem Host nicht installiert. Installiere Tailscale zuerst und versuche es dann erneut.',
       copyFailed: 'Kopieren fehlgeschlagen — bitte Text manuell markieren',
       accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
       targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',

+ 1 - 2
frontend/src/i18n/locales/en.ts

@@ -4095,7 +4095,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Tailscale integration',
-      description: 'When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only.',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: 'Setup Required',
@@ -4130,7 +4130,6 @@ export default {
     toast: {
       updated: 'Virtual printer settings updated',
       failedToUpdate: 'Failed to update settings',
-      tailscaleNotAvailable: 'Tailscale is not installed on this host. Install Tailscale first, then try again.',
       copyFailed: 'Failed to copy — try selecting the text manually',
       accessCodeRequired: 'Please set an access code first',
       targetPrinterRequired: 'Please select a target printer first',

+ 2 - 3
frontend/src/i18n/locales/fr.ts

@@ -3667,7 +3667,7 @@ export default {
     branch: 'Branche',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: 'Manuel uniquement',
@@ -4074,7 +4074,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Intégration Tailscale',
-      description: 'Lorsqu\'activé, utilise Tailscale pour des certificats TLS de confiance. Désactiver pour n\'utiliser que des certificats auto-signés.',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: 'Configuration requise',
@@ -4103,7 +4103,6 @@ export default {
     toast: {
       updated: 'Réglages virtuels mis à jour',
       failedToUpdate: 'Échec mise à jour',
-      tailscaleNotAvailable: 'Tailscale n\'est pas installé sur cet hôte. Installez Tailscale puis réessayez.',
       copyFailed: 'Échec de la copie — veuillez sélectionner le texte manuellement',
       accessCodeRequired: 'Code d\'accès requis',
       targetPrinterRequired: 'Imprimante cible requise',

+ 2 - 3
frontend/src/i18n/locales/it.ts

@@ -3666,7 +3666,7 @@ export default {
     branch: 'Branch',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: 'Solo manuale',
@@ -4073,7 +4073,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',
-      description: 'Quando abilitato, utilizza Tailscale per certificati TLS affidabili. Disabilita per utilizzare solo certificati auto-firmati.',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: 'Configurazione necessaria',
@@ -4102,7 +4102,6 @@ export default {
     toast: {
       updated: 'Impostazioni stampante virtuale aggiornate',
       failedToUpdate: 'Aggiornamento impostazioni fallito',
-      tailscaleNotAvailable: 'Tailscale non è installato su questo host. Installa prima Tailscale, poi riprova.',
       copyFailed: 'Copia non riuscita — seleziona il testo manualmente',
       accessCodeRequired: 'Imposta prima un codice accesso',
       targetPrinterRequired: 'Seleziona prima una stampante target',

+ 2 - 3
frontend/src/i18n/locales/ja.ts

@@ -3679,7 +3679,7 @@ export default {
     branch: 'ブランチ',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: '手動のみ',
@@ -4086,7 +4086,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Tailscale統合',
-      description: '有効にすると、Tailscaleを使用して信頼できるTLS証明書を使用します。自己署名証明書のみを使用する場合は無効にします。',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: 'セットアップが必要です',
@@ -4115,7 +4115,6 @@ export default {
     toast: {
       updated: '仮想プリンター設定を更新しました',
       failedToUpdate: '設定の更新に失敗しました',
-      tailscaleNotAvailable: 'このホストにTailscaleがインストールされていません。先にTailscaleをインストールしてから再試行してください。',
       copyFailed: 'コピーに失敗しました — テキストを手動で選択してください',
       accessCodeRequired: '先にアクセスコードを設定してください',
       targetPrinterRequired: '先にターゲットプリンターを選択してください',

+ 2 - 3
frontend/src/i18n/locales/pt-BR.ts

@@ -3666,7 +3666,7 @@ export default {
     branch: 'Branch',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: 'Apenas manual',
@@ -4073,7 +4073,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Integração Tailscale',
-      description: 'Quando ativado, usa Tailscale para certificados TLS confiáveis. Desative para usar apenas certificado autoassinado.',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: 'Configuração Necessária',
@@ -4102,7 +4102,6 @@ export default {
     toast: {
       updated: 'Configurações da impressora virtual atualizadas',
       failedToUpdate: 'Falha ao atualizar as configurações',
-      tailscaleNotAvailable: 'Tailscale não está instalado neste host. Instale o Tailscale primeiro e tente novamente.',
       copyFailed: 'Falha ao copiar — selecione o texto manualmente',
       accessCodeRequired: 'Defina um código de acesso primeiro',
       targetPrinterRequired: 'Selecione uma impressora alvo primeiro',

+ 2 - 3
frontend/src/i18n/locales/zh-CN.ts

@@ -3667,7 +3667,7 @@ export default {
     branch: '分支',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-    providerGitLab: 'GitLab',	
+    providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: '仅手动',
@@ -4074,7 +4074,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Tailscale 集成',
-      description: '启用后,使用 Tailscale 获取受信任的 TLS 证书。禁用则仅使用自签名证书。',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: '需要设置',
@@ -4109,7 +4109,6 @@ export default {
     toast: {
       updated: '虚拟打印机设置已更新',
       failedToUpdate: '更新设置失败',
-      tailscaleNotAvailable: '此主机上未安装 Tailscale。请先安装 Tailscale,然后重试。',
       copyFailed: '复制失败 — 请手动选中文本',
       accessCodeRequired: '请先设置访问码',
       targetPrinterRequired: '请先选择目标打印机',

+ 2 - 3
frontend/src/i18n/locales/zh-TW.ts

@@ -3667,7 +3667,7 @@ export default {
     branch: '分支',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     manualOnly: '僅手動',
@@ -4074,7 +4074,7 @@ export default {
     },
     tailscaleDisabled: {
       title: 'Tailscale 整合',
-      description: '啟用後,使用 Tailscale 取得受信任的 TLS 憑證。停用則僅使用自簽憑證。',
+      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
     },
     setupRequired: {
       title: '需要設定',
@@ -4109,7 +4109,6 @@ export default {
     toast: {
       updated: '虛擬印表機設定已更新',
       failedToUpdate: '更新設定失敗',
-      tailscaleNotAvailable: '此主機上未安裝 Tailscale。請先安裝 Tailscale,然後重試。',
       copyFailed: '複製失敗 — 請手動選取文字',
       accessCodeRequired: '請先設定存取碼',
       targetPrinterRequired: '請先選擇目標印表機',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DjTddrY6.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="./assets/index-D7M74u_C.js"></script>
+    <script type="module" crossorigin src="./assets/index-DjTddrY6.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff