Просмотр исходного кода

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 недель назад
Родитель
Сommit
64899a8ca4

Разница между файлами не показана из-за своего большого размера
+ 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:
 **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
 - 🔒 **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
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
 - ⚡ **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_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
     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
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "review" is the new name for "queue" (pending review before archiving)
     # "print_queue" archives and adds to print queue (unassigned)
     # "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:
     if body.remote_interface_ip is not None:
         vp.remote_interface_ip = body.remote_interface_ip
         vp.remote_interface_ip = body.remote_interface_ip
     if body.tailscale_disabled is not None:
     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
         vp.tailscale_disabled = body.tailscale_disabled
 
 
     # Auto-inherit model when switching to proxy mode with existing target printer
     # 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)
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
         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:
     def delete_printer_certificate(self) -> None:
         """Delete only the printer certificate (preserves CA)."""
         """Delete only the printer certificate (preserves CA)."""
         for path in [self.cert_path, self.key_path]:
         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_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 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.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
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -158,9 +157,6 @@ class VirtualPrinterInstance:
             shared_ca_dir=shared_ca_dir,
             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
         # Pending files for MQTT correlation
         self._pending_files: dict[str, Path] = {}
         self._pending_files: dict[str, Path] = {}
 
 
@@ -174,8 +170,6 @@ class VirtualPrinterInstance:
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp_proxy: SSDPProxy | None = None
         self._ssdp_proxy: SSDPProxy | None = None
         self._tasks: list[asyncio.Task] = []
         self._tasks: list[asyncio.Task] = []
-        self._cert_renewal_task: asyncio.Task | None = None
-        self._cert_restart_task: asyncio.Task | None = None
 
 
     @property
     @property
     def serial(self) -> str:
     def serial(self) -> str:
@@ -447,135 +441,14 @@ class VirtualPrinterInstance:
 
 
     # -- Service lifecycle --
     # -- 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.
         """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()
         cert_path, key_path = self.generate_certificates()
         advertise = self.remote_interface_ip or self.bind_ip or ""
         advertise = self.remote_interface_ip or self.bind_ip or ""
         return cert_path, key_path, advertise
         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."""
         """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)
         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
         bind_addr = self.bind_ip or "0.0.0.0"  # nosec B104
 
 
         async def run_with_logging(coro, svc_name):
         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)
         logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr)
 
 
     async def stop_server(self) -> None:
     async def stop_server(self) -> None:
         """Stop server-mode services."""
         """Stop server-mode services."""
-        await self._cancel_renewal_task()
-        await self._cancel_restart_task()
         if self._mqtt_bridge:
         if self._mqtt_bridge:
             try:
             try:
                 await self._mqtt_bridge.stop()
                 await self._mqtt_bridge.stop()
@@ -742,7 +609,7 @@ class VirtualPrinterInstance:
         """Start proxy mode services for this instance."""
         """Start proxy mode services for this instance."""
         logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
         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(
         self._proxy = SlicerProxyManager(
             target_host=self.target_printer_ip,
             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:
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
         """Start single-interface SSDP server as fallback for proxy mode."""
         """Start single-interface SSDP server as fallback for proxy mode."""
         self._ssdp = VirtualPrinterSSDPServer(
         self._ssdp = VirtualPrinterSSDPServer(
@@ -819,8 +682,6 @@ class VirtualPrinterInstance:
 
 
     async def stop_proxy(self) -> None:
     async def stop_proxy(self) -> None:
         """Stop proxy mode services for this instance."""
         """Stop proxy mode services for this instance."""
-        await self._cancel_renewal_task()
-        await self._cancel_restart_task()
         if self._proxy:
         if self._proxy:
             await self._proxy.stop()
             await self._proxy.stop()
             self._proxy = None
             self._proxy = None
@@ -849,8 +710,6 @@ class VirtualPrinterInstance:
             "running": self.is_running,
             "running": self.is_running,
             "pending_files": len(self._pending_files),
             "pending_files": len(self._pending_files),
         }
         }
-        if self.tailscale_fqdn:
-            status["tailscale_fqdn"] = self.tailscale_fqdn
         if self.is_proxy and self._proxy:
         if self.is_proxy and self._proxy:
             status["proxy"] = self._proxy.get_status()
             status["proxy"] = self._proxy.get_status()
         return status
         return status
@@ -947,7 +806,6 @@ class VirtualPrinterManager:
                 or instance.remote_interface_ip != (vp.remote_interface_ip or "")
                 or instance.remote_interface_ip != (vp.remote_interface_ip or "")
                 or instance.target_printer_id != vp.target_printer_id
                 or instance.target_printer_id != vp.target_printer_id
                 or instance.auto_dispatch != vp.auto_dispatch
                 or instance.auto_dispatch != vp.auto_dispatch
-                or instance.tailscale_disabled != vp.tailscale_disabled
             )
             )
 
 
             if changed:
             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 asyncio
 import json
 import json
 import logging
 import logging
 import os
 import os
-import re
 import shutil
 import shutil
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
-from cryptography import x509
-
 logger = logging.getLogger(__name__)
 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
 # Minimal environment for tailscale subprocess — passes OS/shell variables that
 # tailscale needs to locate its socket and config, but strips application secrets
 # 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.
 # (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for.
@@ -82,7 +67,7 @@ class TailscaleStatus:
 
 
 
 
 class TailscaleService:
 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
     All methods are safe to call when Tailscale is absent — they return
     sensible defaults and never raise exceptions.
     sensible defaults and never raise exceptions.
@@ -92,21 +77,15 @@ class TailscaleService:
 
 
     @classmethod
     @classmethod
     def _log_docker_socket_hint(cls) -> None:
     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:
         if cls._docker_hint_logged:
             return
             return
         if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
         if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
             logger.info(
             logger.info(
                 "Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
                 "Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
                 "Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` "
                 "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
             cls._docker_hint_logged = True
 
 
@@ -114,8 +93,6 @@ class TailscaleService:
         """Run a tailscale subcommand and return (returncode, stdout, stderr).
         """Run a tailscale subcommand and return (returncode, stdout, stderr).
 
 
         Resolves the binary to an absolute path to guard against PATH hijacking.
         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")
         binary = shutil.which("tailscale")
         if not binary:
         if not binary:
@@ -138,8 +115,6 @@ class TailscaleService:
     async def get_status(self) -> TailscaleStatus:
     async def get_status(self) -> TailscaleStatus:
         """Query Tailscale status and return machine identity.
         """Query Tailscale status and return machine identity.
 
 
-        Runs: tailscale status --json
-
         Returns TailscaleStatus(available=False) if the binary is missing,
         Returns TailscaleStatus(available=False) if the binary is missing,
         the daemon is not running, or any other error occurs.
         the daemon is not running, or any other error occurs.
         """
         """
@@ -165,9 +140,6 @@ class TailscaleService:
             )
             )
 
 
         if returncode is None or returncode != 0:
         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()
             self._log_docker_socket_hint()
             return TailscaleStatus(
             return TailscaleStatus(
                 available=False,
                 available=False,
@@ -201,7 +173,6 @@ class TailscaleService:
                 error="Tailscale not connected (no DNSName)",
                 error="Tailscale not connected (no DNSName)",
             )
             )
 
 
-        # Split "myhost.tailnetname.ts.net" into hostname + tailnet_name
         parts = fqdn.split(".", 1)
         parts = fqdn.split(".", 1)
         hostname = parts[0]
         hostname = parts[0]
         tailnet_name = parts[1] if len(parts) > 1 else ""
         tailnet_name = parts[1] if len(parts) > 1 else ""
@@ -217,122 +188,6 @@ class TailscaleService:
             tailscale_ips=tailscale_ips,
             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
 # Module-level singleton — import this in other modules
 tailscale_service = TailscaleService()
 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
         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.asyncio
     @pytest.mark.integration
     @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(
         create_resp = await async_client.post(
             "/api/v1/virtual-printers",
             "/api/v1/virtual-printers",
             json={
             json={
-                "name": "TestTailscaleGuard",
+                "name": "TestTailscaleToggle",
                 "mode": "immediate",
                 "mode": "immediate",
                 "access_code": "12345678",
                 "access_code": "12345678",
             },
             },
         )
         )
         assert create_resp.status_code == 200
         assert create_resp.status_code == 200
         vp_id = create_resp.json()["id"]
         vp_id = create_resp.json()["id"]
-        # New VPs default to tailscale_disabled=True (opt-in).
         assert create_resp.json()["tailscale_disabled"] is True
         assert create_resp.json()["tailscale_disabled"] is True
 
 
-        mock_status = TailscaleStatus(
-            available=False,
-            hostname="",
-            tailnet_name="",
-            fqdn="",
-            error="tailscale binary not found",
-        )
         with patch(
         with patch(
             "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
             "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}",
                 f"/api/v1/virtual-printers/{vp_id}",
                 json={"tailscale_disabled": False},
                 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}",
                 f"/api/v1/virtual-printers/{vp_id}",
                 json={"tailscale_disabled": True},
                 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
 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
 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:
 class TestTailscaleService:
-    """Tests for TailscaleService CLI wrapper."""
-
-    # -- get_status --
+    """Tests for TailscaleService CLI wrapper — get_status only."""
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_status_binary_not_found(self):
     async def test_get_status_binary_not_found(self):
@@ -65,7 +30,7 @@ class TestTailscaleService:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_status_command_fails(self):
     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
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
 
         svc = TailscaleService()
         svc = TailscaleService()
@@ -104,726 +69,35 @@ class TestTailscaleService:
         assert status.tailnet_name == "example.ts.net"
         assert status.tailnet_name == "example.ts.net"
         assert "100.1.2.3" in status.tailscale_ips
         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
     @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
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
 
+        payload = {"Self": {"DNSName": "", "TailscaleIPs": []}}
         svc = TailscaleService()
         svc = TailscaleService()
-        cert_path = tmp_path / "c.crt"
-        key_path = tmp_path / "k.key"
         with (
         with (
+            patch("shutil.which", return_value="/usr/bin/tailscale"),
             patch.object(
             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
     @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
         from backend.app.services.virtual_printer.tailscale import TailscaleService
 
 
         svc = TailscaleService()
         svc = TailscaleService()
         with (
         with (
             patch("shutil.which", return_value="/usr/bin/tailscale"),
             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()
             status = await svc.get_status()
 
 
         assert status.available is False
         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)
             mock_remove.assert_called_once_with(1)
 
 
     @pytest.mark.asyncio
     @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
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
 
         inst = VirtualPrinterInstance(
         inst = VirtualPrinterInstance(
@@ -947,14 +952,9 @@ class TestVirtualPrinterManager:
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
         self._setup_sync_mocks(manager, [db_vp], tmp_path)
 
 
         with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
         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:
 class TestFTPSession:

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

@@ -19,6 +19,14 @@ vi.mock('../../api/client', () => ({
   multiVirtualPrinterApi: {
   multiVirtualPrinterApi: {
     update: vi.fn().mockResolvedValue({}),
     update: vi.fn().mockResolvedValue({}),
     remove: vi.fn().mockResolvedValue({}),
     remove: vi.fn().mockResolvedValue({}),
+    getTailscaleStatus: vi.fn().mockResolvedValue({
+      available: false,
+      fqdn: '',
+      hostname: '',
+      tailnet_name: '',
+      tailscale_ips: [],
+      error: null,
+    }),
   },
   },
   api: {
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
     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', () => {
 describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
+  const fqdn = 'test-host.tail1234.ts.net';
+
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
     vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
     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() {
   function getCopyButton() {
     // The copy button is a <button> with a title attribute. Use title to locate it.
     // The copy button is a <button> with a title attribute. Use title to locate it.
     const candidates = screen.getAllByRole('button');
     const candidates = screen.getAllByRole('button');
@@ -351,13 +340,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
       configurable: true,
       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} />);
     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 user.click(copyBtn);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -374,12 +364,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     const execCommandMock = vi.fn().mockReturnValue(true);
     const execCommandMock = vi.fn().mockReturnValue(true);
     document.execCommand = execCommandMock;
     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} />);
     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 user.click(copyBtn);
 
 
     await waitFor(() => {
     await waitFor(() => {
@@ -399,12 +391,14 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
       throw new Error('synthetic execCommand failure');
       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} />);
     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 user.click(copyBtn);
 
 
     // The `finally` block must remove the textarea regardless of the exception.
     // 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;
   pending_files: number;
   target_printer_ip?: string;  // For proxy mode
   target_printer_ip?: string;  // For proxy mode
   proxy?: VirtualPrinterProxyStatus;  // For proxy mode
   proxy?: VirtualPrinterProxyStatus;  // For proxy mode
-  tailscale_fqdn?: string;  // Set when Tailscale cert is active
 }
 }
 
 
 export interface VirtualPrinterSettings {
 export interface VirtualPrinterSettings {
@@ -5740,7 +5739,7 @@ export interface VirtualPrinterConfig {
   bind_ip: string | null;
   bind_ip: string | null;
   remote_interface_ip: string | null;
   remote_interface_ip: string | null;
   position: number;
   position: number;
-  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus; tailscale_fqdn?: string };
+  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
 }
 }
 
 
 export interface VirtualPrinterListResponse {
 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [fqdnCopied, setFqdnCopied] = 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) => {
   const handleCopyFqdn = async (e: React.MouseEvent) => {
     e.stopPropagation();
     e.stopPropagation();
-    const fqdn = printer.status?.tailscale_fqdn;
+    const fqdn = tailscaleFqdn;
     if (!fqdn) return;
     if (!fqdn) return;
     let ok = false;
     let ok = false;
     // Modern API — only works in secure contexts (HTTPS / localhost).
     // Modern API — only works in secure contexts (HTTPS / localhost).
@@ -126,13 +138,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setPendingAction(null);
       setPendingAction(null);
     },
     },
     onError: (error: Error) => {
     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);
       setLocalEnabled(printer.enabled);
       setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
       setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalTargetPrinterId(printer.target_printer_id);
@@ -301,12 +307,15 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </button>
               </button>
             </div>
             </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">
             <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">
                 <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" />
                   <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
                   <button
                     onClick={handleCopyFqdn}
                     onClick={handleCopyFqdn}
                     className="p-0.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
                     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: {
     tailscaleDisabled: {
       title: 'Tailscale-Integration',
       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: {
     setupRequired: {
       title: 'Einrichtung erforderlich',
       title: 'Einrichtung erforderlich',
@@ -4121,7 +4121,6 @@ export default {
     toast: {
     toast: {
       updated: 'Virtuelle Druckereinstellungen aktualisiert',
       updated: 'Virtuelle Druckereinstellungen aktualisiert',
       failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
       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',
       copyFailed: 'Kopieren fehlgeschlagen — bitte Text manuell markieren',
       accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
       accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
       targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',
       targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',

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

@@ -4095,7 +4095,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale integration',
       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: {
     setupRequired: {
       title: 'Setup Required',
       title: 'Setup Required',
@@ -4130,7 +4130,6 @@ export default {
     toast: {
     toast: {
       updated: 'Virtual printer settings updated',
       updated: 'Virtual printer settings updated',
       failedToUpdate: 'Failed to update settings',
       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',
       copyFailed: 'Failed to copy — try selecting the text manually',
       accessCodeRequired: 'Please set an access code first',
       accessCodeRequired: 'Please set an access code first',
       targetPrinterRequired: 'Please select a target printer first',
       targetPrinterRequired: 'Please select a target printer first',

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

@@ -3667,7 +3667,7 @@ export default {
     branch: 'Branche',
     branch: 'Branche',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: 'Manuel uniquement',
     manualOnly: 'Manuel uniquement',
@@ -4074,7 +4074,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Intégration Tailscale',
       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: {
     setupRequired: {
       title: 'Configuration requise',
       title: 'Configuration requise',
@@ -4103,7 +4103,6 @@ export default {
     toast: {
     toast: {
       updated: 'Réglages virtuels mis à jour',
       updated: 'Réglages virtuels mis à jour',
       failedToUpdate: 'Échec mise à 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',
       copyFailed: 'Échec de la copie — veuillez sélectionner le texte manuellement',
       accessCodeRequired: 'Code d\'accès requis',
       accessCodeRequired: 'Code d\'accès requis',
       targetPrinterRequired: 'Imprimante cible requise',
       targetPrinterRequired: 'Imprimante cible requise',

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

@@ -3666,7 +3666,7 @@ export default {
     branch: 'Branch',
     branch: 'Branch',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: 'Solo manuale',
     manualOnly: 'Solo manuale',
@@ -4073,7 +4073,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',
       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: {
     setupRequired: {
       title: 'Configurazione necessaria',
       title: 'Configurazione necessaria',
@@ -4102,7 +4102,6 @@ export default {
     toast: {
     toast: {
       updated: 'Impostazioni stampante virtuale aggiornate',
       updated: 'Impostazioni stampante virtuale aggiornate',
       failedToUpdate: 'Aggiornamento impostazioni fallito',
       failedToUpdate: 'Aggiornamento impostazioni fallito',
-      tailscaleNotAvailable: 'Tailscale non è installato su questo host. Installa prima Tailscale, poi riprova.',
       copyFailed: 'Copia non riuscita — seleziona il testo manualmente',
       copyFailed: 'Copia non riuscita — seleziona il testo manualmente',
       accessCodeRequired: 'Imposta prima un codice accesso',
       accessCodeRequired: 'Imposta prima un codice accesso',
       targetPrinterRequired: 'Seleziona prima una stampante target',
       targetPrinterRequired: 'Seleziona prima una stampante target',

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

@@ -3679,7 +3679,7 @@ export default {
     branch: 'ブランチ',
     branch: 'ブランチ',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: '手動のみ',
     manualOnly: '手動のみ',
@@ -4086,7 +4086,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale統合',
       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: {
     setupRequired: {
       title: 'セットアップが必要です',
       title: 'セットアップが必要です',
@@ -4115,7 +4115,6 @@ export default {
     toast: {
     toast: {
       updated: '仮想プリンター設定を更新しました',
       updated: '仮想プリンター設定を更新しました',
       failedToUpdate: '設定の更新に失敗しました',
       failedToUpdate: '設定の更新に失敗しました',
-      tailscaleNotAvailable: 'このホストにTailscaleがインストールされていません。先にTailscaleをインストールしてから再試行してください。',
       copyFailed: 'コピーに失敗しました — テキストを手動で選択してください',
       copyFailed: 'コピーに失敗しました — テキストを手動で選択してください',
       accessCodeRequired: '先にアクセスコードを設定してください',
       accessCodeRequired: '先にアクセスコードを設定してください',
       targetPrinterRequired: '先にターゲットプリンターを選択してください',
       targetPrinterRequired: '先にターゲットプリンターを選択してください',

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

@@ -3666,7 +3666,7 @@ export default {
     branch: 'Branch',
     branch: 'Branch',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: 'Apenas manual',
     manualOnly: 'Apenas manual',
@@ -4073,7 +4073,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integração Tailscale',
       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: {
     setupRequired: {
       title: 'Configuração Necessária',
       title: 'Configuração Necessária',
@@ -4102,7 +4102,6 @@ export default {
     toast: {
     toast: {
       updated: 'Configurações da impressora virtual atualizadas',
       updated: 'Configurações da impressora virtual atualizadas',
       failedToUpdate: 'Falha ao atualizar as configurações',
       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',
       copyFailed: 'Falha ao copiar — selecione o texto manualmente',
       accessCodeRequired: 'Defina um código de acesso primeiro',
       accessCodeRequired: 'Defina um código de acesso primeiro',
       targetPrinterRequired: 'Selecione uma impressora alvo primeiro',
       targetPrinterRequired: 'Selecione uma impressora alvo primeiro',

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

@@ -3667,7 +3667,7 @@ export default {
     branch: '分支',
     branch: '分支',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-    providerGitLab: 'GitLab',	
+    providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: '仅手动',
     manualOnly: '仅手动',
@@ -4074,7 +4074,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 集成',
       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: {
     setupRequired: {
       title: '需要设置',
       title: '需要设置',
@@ -4109,7 +4109,6 @@ export default {
     toast: {
     toast: {
       updated: '虚拟打印机设置已更新',
       updated: '虚拟打印机设置已更新',
       failedToUpdate: '更新设置失败',
       failedToUpdate: '更新设置失败',
-      tailscaleNotAvailable: '此主机上未安装 Tailscale。请先安装 Tailscale,然后重试。',
       copyFailed: '复制失败 — 请手动选中文本',
       copyFailed: '复制失败 — 请手动选中文本',
       accessCodeRequired: '请先设置访问码',
       accessCodeRequired: '请先设置访问码',
       targetPrinterRequired: '请先选择目标打印机',
       targetPrinterRequired: '请先选择目标打印机',

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

@@ -3667,7 +3667,7 @@ export default {
     branch: '分支',
     branch: '分支',
     provider: 'Git Provider',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',
     providerGitHub: 'GitHub',
-	providerGitLab: 'GitLab',	
+	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
 	providerGitea: 'Gitea',
     providerForgejo: 'Forgejo',
     providerForgejo: 'Forgejo',
     manualOnly: '僅手動',
     manualOnly: '僅手動',
@@ -4074,7 +4074,7 @@ export default {
     },
     },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 整合',
       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: {
     setupRequired: {
       title: '需要設定',
       title: '需要設定',
@@ -4109,7 +4109,6 @@ export default {
     toast: {
     toast: {
       updated: '虛擬印表機設定已更新',
       updated: '虛擬印表機設定已更新',
       failedToUpdate: '更新設定失敗',
       failedToUpdate: '更新設定失敗',
-      tailscaleNotAvailable: '此主機上未安裝 Tailscale。請先安裝 Tailscale,然後重試。',
       copyFailed: '複製失敗 — 請手動選取文字',
       copyFailed: '複製失敗 — 請手動選取文字',
       accessCodeRequired: '請先設定存取碼',
       accessCodeRequired: '請先設定存取碼',
       targetPrinterRequired: '請先選擇目標印表機',
       targetPrinterRequired: '請先選擇目標印表機',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DjTddrY6.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов