diff --git a/CHANGELOG.md b/CHANGELOG.md index 630a51f13..b189ec0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b2] - Unreleased +### Changed +- **Virtual Printer Tailscale toggle no longer provisions Let's Encrypt certs — it's now informational** — The original promise of the `tailscale_disabled` toggle was that flipping it on would obtain an LE cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into the slicer. End-to-end testing exposed that this was always going to fail: BambuStudio and OrcaSlicer both refuse hostname input in the Add Printer dialog (IP-only), and — more fundamentally — 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)` + `mosquitto_tls_opts_set(verify_peer=1)` + `mosquitto_tls_insecure_set(true)` — chain validation against BBL CA only, hostname check intentionally skipped (because Bambu's printer cert CN is the device serial, not an IP/hostname). LE-issued 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; the LE provisioning was dead code for slicer connections. **What stays:** the toggle, the `/virtual-printers/tailscale-status` route, the docker socket mount, and the host-level Tailscale information surfaced on the VP card (IP + MagicDNS hostname + copy button) so users know what to paste into the slicer when they pick the Tailscale interface from the bind_ip dropdown. Tailscale's role is now strictly **network reach** — private WireGuard tunnel to the VP from any tailnet device, no port forwarding — exactly the same trust burden as LAN. **What goes:** `provision_cert` / `ensure_cert` / `cert_needs_renewal` and the daily renewal task / restart-on-renewal plumbing on the manager (`_cert_renewal_task`, `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`, `_cancel_renewal_task`, `_cancel_restart_task`); the `tailscale_fqdn` field surfaced via VP status (cert side-effect); the `tailscale_not_available` 409 guard on toggle-enable in both `routes/virtual_printers.py` and `routes/settings.py` (toggle is informational, daemon presence doesn't block flipping it); `CertificateService.{ts_cert_path, ts_key_path, use_tailscale_cert}` and the LE cert files on disk (`virtual_printer_ts.{crt,key}` left in place per-VP — harmless residue, can be deleted manually). The `tailscale_disabled` DB column is **kept** as the persisted toggle state. Tailscale FQDN/IP on the VP card is now sourced from the existing `/tailscale/status` endpoint (host-level) rather than from per-VP cert provisioning side-effect — the data is the same regardless of which VP you're looking at, since each host has one Tailscale identity. **Wiki, README, and i18n copy updated across all 8 locales** to drop the "no cert import needed" framing — toggle's helper text now says it surfaces the Tailscale address and that CA import is unchanged. **Tests:** `test_tailscale.py` reduced to the 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` rewritten as `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is informational — `remove_instance` must NOT be called when only `tailscale_disabled` changes); `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` collapsed to a single `TestVirtualPrinterTailscaleToggleAPI::test_toggle_does_not_consult_tailscale_daemon` that asserts both directions succeed and `get_status` is never called. Frontend `VirtualPrinterCard.test.tsx` mock now stubs `getTailscaleStatus` and the FQDN-copy block drives the FQDN through that query rather than VP status. + ### Added - **Virtual Printer non-proxy modes now mirror the live target printer to the slicer** ([#1193](https://github.com/maziggy/bambuddy/issues/1193) follow-up) — Until now, Immediate / Review / Print Queue VPs looked like a stub Bambu Lab printer to the slicer: AMS dropdowns were empty, no live state, no camera, no per-filament k-profile lookup. The user could send a sliced file and that was it. With this change, the VP fans out the **target printer's live MQTT state** to the slicer (AMS units, FTS / dual-extruder routing, nozzle, temps, k-profiles, AMS load / dry / calibration commands) and proxies the **camera RTSPS stream** on port 322 — so the slicer treats the VP as a fully-functional Bambu printer while Bambuddy's queue / archive / dispatch features stay in the loop. **Architecture (cached-as-base, single source of truth):** the bridge caches the latest real `push_status` and `info.get_version` response from Bambuddy's existing per-printer MQTT subscription (no second session on the printer — firmware in-flight budget unaffected, see #1164). The VP's `_send_status_report` returns a near-byte-identical copy of the real push with only the upload-state-machine fields (sequence_id, command, msg, gcode_state, gcode_file, prepare_percent, subtask_name) overridden under our control, so BambuStudio's Send pre-flight sees exactly the same shape as a direct-to-printer connection. Command responses (extrusion_cali_get, AMS write acks, xcam responses) are fanned out raw — they carry sequence_ids the slicer is waiting on. Slicer-issued commands forward to the real printer except `print.project_file` / `gcode_file`, which are still answered locally because the file lives on Bambuddy. **Field-shape gotchas worth remembering:** (1) Real Bambu printers wire-format push_status JSON with `indent=4` (32 254 bytes for an idle H2D push, vs 14 268 bytes compact) — BambuStudio's Send pre-flight rejects compact JSON silently, so `_publish_to_report` was switched to `json.dumps(payload, indent=4)`. (2) `net.info[*].ip` (little-endian uint32, e.g. 192.168.255.133 → 2248124608) is the FTP destination IP BambuStudio uses for "Send to Printer storage" — it overrides anything else, including the URL hosts the rest of MQTT advertises. The bridge rewrites this to the VP's bind IP on cache, otherwise the slicer FTPs straight to the real printer and bypasses Bambuddy entirely (symptom: "Failed to send" with zero inbound FTP connections on the VP — debug-by-tcpdump if anyone hits it again). (3) `upgrade_state.sn` and any other nested-dict `sn` matching the target serial are rewritten to the VP serial; AMS-hardware serials (`n3f/0.sn` etc.) are left alone — those identify physical AMS units, not the device. (4) `ipcam.rtsp_url` is left unchanged: BambuStudio overrides the URL host with the device IP it bound on (the VP), so the slicer hits the VP's :322 RTSPS port — not the printer's directly. (5) For the slicer's RTSPS to reach the printer, the VP gets a raw `TCPProxy` on `:322 → :322` (same approach proxy mode uses; `cap_net_bind_service` was already in the systemd unit for FTP :990). (6) `extrusion_cali_get` is forwarded — answering it locally hides the user's stored k-profiles. **Setup nuance for camera:** because the slicer authenticates against the printer's RTSPS with whatever access code is in its profile, the VP's access code must match the target printer's access code for the camera path to authenticate. This is a one-time configuration step (Settings → Virtual Printer → set access code = target printer's LAN code, then re-add the VP in Bambu Studio / Orca Slicer). MQTT and FTP work either way; only camera needs the match because RTSPS auth happens between the slicer and the real printer's broker. **Tested e2e** with both BambuStudio and OrcaSlicer against H2D (dual-nozzle, AMS 2 Pro + AMS HT) and X1C (single-nozzle, AMS) across all three non-proxy modes (Immediate / Review / Print Queue) — sync, send, k-profile lookup, AMS configuration from slicer, and live camera all work. **Files:** new `backend/app/services/virtual_printer/mqtt_bridge.py` (caches push_status / get_version, forwards slicer commands, fans out command responses, rewrites identity fields including `net.info[*].ip` LE uint32); `bambu_mqtt.py` gains `register_raw_message_handler` / `unregister_raw_message_handler` / `publish_raw` so the bridge can subscribe to Bambuddy's existing per-printer paho subscription without opening a second session; `mqtt_server.py` switches `_send_status_report` and `_send_version_response` to cached-as-base when the bridge has data, falls back to the original synthetic stubs otherwise; `manager.py` wires the bridge + a raw `TCPProxy` for RTSPS into `start_server` for non-proxy modes whenever a target printer is configured. 25 new tests in `test_vp_mqtt_bridge.py` pin the contract: lifecycle, push_status caching, serial / IP rewriting, get_version-modules cache, selective fan-out (only command responses, never push_status itself), wire format must use `indent=4`, routing of slicer-issued commands (project_file / gcode_file local; everything else forwarded), and the IP-encoding helper against captures from real H2D pushes. Proxy mode is untouched — `SlicerProxyManager` still owns its own MQTT/FTP/RTSP/Bind/Aux proxies in proxy mode and never instantiates `SimpleMQTTServer` or `MQTTBridge`. - **AMS slot Load / Unload from the printer card** ([#891](https://github.com/maziggy/bambuddy/issues/891), reported by @NNeerr00, +1 from @cadtoolbox) — The MQTT primitives for "load filament from a tray" and "unload the currently loaded tray" already existed in `bambu_mqtt.py` (reverse-engineered from BambuStudio captures, including the H2D dual-extruder right-external case captured fresh during this work) but were unused — there was no HTTP route and no UI. Net effect: every Load / Unload had to happen on the printer touchscreen, and external-spool users on dual-nozzle H2D had no way to drive Ext-R from the desktop at all. **Backend:** new `POST /printers/{id}/ams/load?tray_id={int}` and `POST /printers/{id}/ams/unload`, both gated on `Permission.PRINTERS_CONTROL`. The load route validates `tray_id ∈ {0..15, 254, 255}` (AMS slots, single-external/Ext-L, Ext-R respectively) and returns a human-readable target in the success message ("AMS 0 slot 1", "external spool", "Ext-R") so the UI toast tells the user which spool the printer is now feeding from. **MQTT primitive update:** `ams_load_filament` gains a third encoding branch for `tray_id=255` matching the BambuStudio capture verbatim — `ams_id=255, slot_id=0` (the right-extruder index, **not** a slot index — Bambu's load command on dual-extruder externals encodes the destination extruder, not the source slot), `target=255`, and `curr_temp = tar_temp = right-nozzle temp` (read from `state.temperatures["nozzle_2"]`, falling back to 215 °C if the right nozzle is cold or unknown — the printer rejects nonsensical temps, so a warm fallback is safer than `-1`). The existing `tray_id=254` branch is preserved verbatim (`slot_id=254, curr/tar=-1`) since that came from a single-extruder capture and is known to work; no risk of regression on existing single-external setups. **UI:** the existing AMS slot popover (the one with "Re-read RFID") gains two new entries — "Load" (posts `tray_id = ams.id * 4 + slotIdx`) and "Unload" (no params, global on the currently-loaded slot). The external spool slot — which had no popover at all before — gets one with the same Load + Unload entries, and on dual-nozzle H2D each external slot (Ext-L tray_id=254, Ext-R tray_id=255) drives its own extruder. The menu is hidden while `state === 'RUNNING'` (parallels the existing RFID re-read gating). **i18n:** `printers.ams.load`, `printers.ams.unload`, plus four new toast strings (`loadInitiated`, `unloadInitiated`, `failedToLoad`, `failedToUnload`) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 16 new tests pin the contract: 5 unit tests in `test_bambu_mqtt.py::TestAmsLoadFilamentEncoding` (AMS slot encoding, Ext-L preserves legacy capture, Ext-R uses the new captured shape with actual right-nozzle temp, Ext-R falls back to 215 °C when cold, disconnected client doesn't publish); 11 integration tests in `test_printers_api.py::TestAMSLoadUnloadAPI` (load: invalid tray_id 400, not-found 404, not-connected 400, AMS slot success with derived `ams_id*4+slot` math, Ext-L success, Ext-R success, MQTT failure 500; unload: not-found, not-connected, success, MQTT failure 500); 4 frontend tests in `PrintersPageAmsLoadUnload.test.tsx` (Load posts the right tray_id, Unload posts with no params, menu hidden while RUNNING, external spool's tray_id=254 round-trips through the route). diff --git a/README.md b/README.md index 8df28935a..8763408c5 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ You don't need to be a developer for the docs or moderator roles. If you enjoy w **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer: - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate -- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount brings Bambuddy into your tailnet, so virtual printers are reachable from any tailnet device over a private WireGuard tunnel without port forwarding ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side because both Bambu Studio and OrcaSlicer only accept IP addresses in the Add Printer dialog — the Tailscale benefit here is the tunnel, not cert-import elimination. +- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side — the Bambu Studio / OrcaSlicer printer-MQTT trust path uses a bundled BBL CA, not the system trust store, so even a publicly-trusted cert wouldn't help. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination. - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server - 🔑 **Uses printer's access code** — No additional credentials needed - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index c86c42a8b..7ea7559fe 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -928,21 +928,6 @@ async def update_virtual_printer_settings( new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled - # Guard: enabling Tailscale (disabled=False) requires the binary to be present. Otherwise - # the toggle looks like it worked but the service will silently fall back to self-signed. - if tailscale_disabled is False and current_ts_disabled is True: - from backend.app.services.virtual_printer.tailscale import tailscale_service - - ts_status = await tailscale_service.get_status() - if not ts_status.available: - return JSONResponse( - status_code=409, - content={ - "detail": "tailscale_not_available", - "reason": ts_status.error or "tailscale binary not found", - }, - ) - # Validate mode # "review" is the new name for "queue" (pending review before archiving) # "print_queue" archives and adds to print queue (unassigned) diff --git a/backend/app/api/routes/virtual_printers.py b/backend/app/api/routes/virtual_printers.py index cb7d8ae5c..b69f5de93 100644 --- a/backend/app/api/routes/virtual_printers.py +++ b/backend/app/api/routes/virtual_printers.py @@ -342,20 +342,6 @@ async def update_virtual_printer( if body.remote_interface_ip is not None: vp.remote_interface_ip = body.remote_interface_ip if body.tailscale_disabled is not None: - # Guard: user trying to enable Tailscale (disabled=False) must have the binary available. - # Otherwise the toggle looks like it works but silently falls back to self-signed. - if body.tailscale_disabled is False and vp.tailscale_disabled is True: - from backend.app.services.virtual_printer.tailscale import tailscale_service - - ts_status = await tailscale_service.get_status() - if not ts_status.available: - return JSONResponse( - status_code=409, - content={ - "detail": "tailscale_not_available", - "reason": ts_status.error or "tailscale binary not found", - }, - ) vp.tailscale_disabled = body.tailscale_disabled # Auto-inherit model when switching to proxy mode with existing target printer diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py index 1aa07cc76..164c6486b 100644 --- a/backend/app/services/virtual_printer/certificate.py +++ b/backend/app/services/virtual_printer/certificate.py @@ -330,33 +330,6 @@ class CertificateService: logger.info(" Printer: CN=%s", self.serial) return self.cert_path, self.key_path - # -- Tailscale cert support -- - - @property - def ts_cert_path(self) -> Path: - """Path for Tailscale-provisioned cert (separate from self-signed).""" - return self.cert_dir / "virtual_printer_ts.crt" - - @property - def ts_key_path(self) -> Path: - """Path for Tailscale-provisioned private key.""" - return self.cert_dir / "virtual_printer_ts.key" - - async def use_tailscale_cert( - self, - fqdn: str, - tailscale_svc: object, - ) -> tuple[Path, Path] | None: - """Attempt to provision a Tailscale LE cert for fqdn. - - Delegates to tailscale_svc.ensure_cert(). Returns (cert_path, key_path) - on success, None if Tailscale provisioning fails. - """ - ok = await tailscale_svc.ensure_cert(fqdn, self.ts_cert_path, self.ts_key_path) - if ok: - return self.ts_cert_path, self.ts_key_path - return None - def delete_printer_certificate(self) -> None: """Delete only the printer certificate (preserves CA).""" for path in [self.cert_path, self.key_path]: diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index cba314d4b..6a54240f1 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -18,7 +18,6 @@ from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPSer from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer -from backend.app.services.virtual_printer.tailscale import tailscale_service from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy if TYPE_CHECKING: @@ -158,9 +157,6 @@ class VirtualPrinterInstance: shared_ca_dir=shared_ca_dir, ) - # Tailscale FQDN used for this instance (set at start_server/start_proxy time) - self.tailscale_fqdn: str | None = None - # Pending files for MQTT correlation self._pending_files: dict[str, Path] = {} @@ -174,8 +170,6 @@ class VirtualPrinterInstance: self._ssdp: VirtualPrinterSSDPServer | None = None self._ssdp_proxy: SSDPProxy | None = None self._tasks: list[asyncio.Task] = [] - self._cert_renewal_task: asyncio.Task | None = None - self._cert_restart_task: asyncio.Task | None = None @property def serial(self) -> str: @@ -447,135 +441,14 @@ class VirtualPrinterInstance: # -- Service lifecycle -- - async def _cancel_renewal_task(self) -> None: - """Cancel the cert renewal task and await its completion.""" - if self._cert_renewal_task: - self._cert_renewal_task.cancel() - try: - await self._cert_renewal_task - except asyncio.CancelledError: - pass - except Exception as e: - logger.warning("[VP %s] Unexpected error in cert renewal task: %s", self.name, e) - self._cert_renewal_task = None - - async def _cancel_restart_task(self) -> None: - """Cancel the cert restart task and await its completion. - - Skip when the caller IS the restart task itself — stop_server() / - stop_proxy() are called from inside _restart_for_cert_renewal, - which runs AS _cert_restart_task. Cancelling + awaiting self - flags a CancelledError on the next `await` in stop_server, - which tears down the old listeners but never lets start_server - run — the VP would sit on an expired cert until process restart. - """ - task = self._cert_restart_task - if task is asyncio.current_task(): - # Renewal path cleaning up its own restart task: clear the - # reference so future callers don't see a stale task handle, - # but do NOT cancel-and-await ourselves. - self._cert_restart_task = None - return - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - except Exception as e: - logger.warning("[VP %s] Unexpected error in cert restart task: %s", self.name, e) - self._cert_restart_task = None - - async def _restart_for_cert_renewal(self) -> None: - """Restart VP services to load the newly renewed Tailscale cert into TLS listeners.""" - logger.info("[VP %s] Restarting services to apply renewed Tailscale cert", self.name) - try: - if self.is_proxy: - await self.stop_proxy() - await self.start_proxy() - else: - await self.stop_server() - await self.start_server() - except asyncio.CancelledError: - raise - except Exception as e: - logger.error("[VP %s] Failed to restart after cert renewal: %s", self.name, e) - - async def _cert_renewal_loop(self) -> None: - """Daily background check for Tailscale cert renewal while VP is running. - - Checks first, then sleeps, so a cert that was just barely renewed at startup - is not re-checked for another 24 h. When a renewal actually happens the loop - schedules a VP restart so the new cert is loaded into the running TLS listeners. - - _cert_renewal_task is tracked separately from _tasks because it has a different - lifecycle: it runs for the entire lifetime of the VP, not just during service start. - """ - while True: - try: - if self.tailscale_fqdn: - needs_renewal = tailscale_service.cert_needs_renewal( - self._cert_service.ts_cert_path, fqdn=self.tailscale_fqdn - ) - if needs_renewal: - renewed = await self._cert_service.use_tailscale_cert(self.tailscale_fqdn, tailscale_service) - if renewed: - logger.info( - "[VP %s] Tailscale cert renewed for %s, scheduling restart", - self.name, - self.tailscale_fqdn, - ) - # Schedule restart in a separate task; this loop ends here - # so the restart can cleanly cancel _cert_renewal_task and - # create a fresh one via start_server/start_proxy. - self._cert_restart_task = asyncio.create_task( - self._restart_for_cert_renewal(), - name=f"vp_{self.id}_cert_restart", - ) - break - await asyncio.sleep(86400) # check once per day - except asyncio.CancelledError: - break - except Exception as e: - logger.error("[VP %s] Cert renewal loop error: %s", self.name, e) - await asyncio.sleep(3600) # back off 1 h on unexpected error - - async def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]: + def _resolve_cert_and_advertise(self) -> tuple[Path, Path, str]: """Return (cert_path, key_path, advertise_address) for TLS services. - When Tailscale is available, provisions a LE cert and returns the - Tailscale FQDN as the advertise address so SSDP broadcasts the hostname - that matches the trusted cert. - - Falls back to the self-signed cert and IP-based advertising when - Tailscale is absent or provisioning fails. + Always uses the self-signed cert chain (signed by `bbl_ca`). The user + imports `bbl_ca.crt` once into the slicer; per-VP certs validate from + there. Tailscale exposure is handled by the user picking the Tailscale + IP in the bind_ip dropdown. """ - if self.tailscale_disabled: - logger.info("[VP %s] Tailscale integration disabled by user, using self-signed cert", self.name) - else: - try: - ts_status = await tailscale_service.get_status() - if ts_status.available: - ts_result = await self._cert_service.use_tailscale_cert(ts_status.fqdn, tailscale_service) - if ts_result: - self.tailscale_fqdn = ts_status.fqdn - logger.info("[VP %s] Using Tailscale cert for %s", self.name, ts_status.fqdn) - return ts_result[0], ts_result[1], ts_status.fqdn - logger.warning( - "[VP %s] Tailscale available (%s) but cert provisioning failed, falling back to self-signed cert", - self.name, - ts_status.fqdn, - ) - else: - logger.info( - "[VP %s] Tailscale not available (%s), using self-signed cert", - self.name, - ts_status.error or "not connected", - ) - except Exception as e: - logger.warning("[VP %s] Tailscale cert check failed, falling back to self-signed: %s", self.name, e) - - self.tailscale_fqdn = None cert_path, key_path = self.generate_certificates() advertise = self.remote_interface_ip or self.bind_ip or "" return cert_path, key_path, advertise @@ -584,7 +457,7 @@ class VirtualPrinterInstance: """Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip.""" logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip) - cert_path, key_path, advertise_addr = await self._resolve_cert_and_advertise() + cert_path, key_path, advertise_addr = self._resolve_cert_and_advertise() bind_addr = self.bind_ip or "0.0.0.0" # nosec B104 async def run_with_logging(coro, svc_name): @@ -700,16 +573,10 @@ class VirtualPrinterInstance: ) ) - # Guard against double-start: cancel any orphaned task before creating a new one - await self._cancel_renewal_task() - self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal") - logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr) async def stop_server(self) -> None: """Stop server-mode services.""" - await self._cancel_renewal_task() - await self._cancel_restart_task() if self._mqtt_bridge: try: await self._mqtt_bridge.stop() @@ -742,7 +609,7 @@ class VirtualPrinterInstance: """Start proxy mode services for this instance.""" logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip) - cert_path, key_path, _ = await self._resolve_cert_and_advertise() + cert_path, key_path, _ = self._resolve_cert_and_advertise() self._proxy = SlicerProxyManager( target_host=self.target_printer_ip, @@ -797,10 +664,6 @@ class VirtualPrinterInstance: ) ) - # Guard against double-start: cancel any orphaned task before creating a new one - await self._cancel_renewal_task() - self._cert_renewal_task = asyncio.create_task(self._cert_renewal_loop(), name=f"vp_{self.id}_cert_renewal") - def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None: """Start single-interface SSDP server as fallback for proxy mode.""" self._ssdp = VirtualPrinterSSDPServer( @@ -819,8 +682,6 @@ class VirtualPrinterInstance: async def stop_proxy(self) -> None: """Stop proxy mode services for this instance.""" - await self._cancel_renewal_task() - await self._cancel_restart_task() if self._proxy: await self._proxy.stop() self._proxy = None @@ -849,8 +710,6 @@ class VirtualPrinterInstance: "running": self.is_running, "pending_files": len(self._pending_files), } - if self.tailscale_fqdn: - status["tailscale_fqdn"] = self.tailscale_fqdn if self.is_proxy and self._proxy: status["proxy"] = self._proxy.get_status() return status @@ -947,7 +806,6 @@ class VirtualPrinterManager: or instance.remote_interface_ip != (vp.remote_interface_ip or "") or instance.target_printer_id != vp.target_printer_id or instance.auto_dispatch != vp.auto_dispatch - or instance.tailscale_disabled != vp.tailscale_disabled ) if changed: diff --git a/backend/app/services/virtual_printer/tailscale.py b/backend/app/services/virtual_printer/tailscale.py index 0e527d9db..c2685c508 100644 --- a/backend/app/services/virtual_printer/tailscale.py +++ b/backend/app/services/virtual_printer/tailscale.py @@ -1,43 +1,28 @@ -"""Tailscale integration for virtual printer certificate provisioning. +"""Tailscale presence detection for virtual printers. -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. +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. -Falls back gracefully when Tailscale is unavailable. +Historical note: this module previously provisioned Let's Encrypt certs via +`tailscale cert` so the slicer would not need a manual CA import. That path +was removed because BambuStudio's printer-MQTT trust path validates only +against its bundled BBL CA (not the system trust store), so LE-signed certs +are rejected regardless of hostname/IP. The self-signed CA flow (with one- +time `bbl_ca.crt` import into the slicer) is the only viable trust mechanism; +Tailscale's role is now strictly network reach. """ import asyncio import json import logging import os -import re import shutil from dataclasses import dataclass, field -from datetime import datetime, timezone from pathlib import Path -from cryptography import x509 - logger = logging.getLogger(__name__) -# Renew when fewer than this many days remain on the LE cert (LE issues 90-day certs; -# Let's Encrypt recommends renewing at 30 days remaining) -TS_CERT_EXPIRY_THRESHOLD_DAYS = 30 - -# Defensive FQDN validation before passing to subprocess -_FQDN_RE = re.compile( - r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$", - re.IGNORECASE, -) - -# Detect tailnets where HTTPS cert generation is disabled — common for company/school -# tailnets where the user is not a Tailscale admin. -_HTTPS_DISABLED_RE = re.compile( - r"(https? cert.*disabled|not enabled.*tailnet|cert.*not.*enabled)", - re.IGNORECASE, -) - # Minimal environment for tailscale subprocess — passes OS/shell variables that # tailscale needs to locate its socket and config, but strips application secrets # (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for. @@ -82,7 +67,7 @@ class TailscaleStatus: class TailscaleService: - """Wraps Tailscale CLI commands for certificate provisioning. + """Wraps `tailscale status` for presence detection. All methods are safe to call when Tailscale is absent — they return sensible defaults and never raise exceptions. @@ -92,21 +77,15 @@ class TailscaleService: @classmethod def _log_docker_socket_hint(cls) -> None: - """Log a one-time hint when running in Docker without the Tailscale socket mounted. - - Fires in both states: (a) tailscale binary missing and (b) binary present - but the host socket isn't mounted into the container. The binary alone - can't talk to the daemon — the host's tailscaled socket needs to be - volume-mounted in docker-compose.yml. - """ + """Log a one-time hint when running in Docker without the Tailscale socket mounted.""" if cls._docker_hint_logged: return if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists(): logger.info( "Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. " "Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` " - "to docker-compose.yml (under volumes:) and run Tailscale on the host to enable " - "Let's Encrypt certs for virtual printers." + "to docker-compose.yml (under volumes:) and run Tailscale on the host to " + "expose virtual printers over your tailnet." ) cls._docker_hint_logged = True @@ -114,8 +93,6 @@ class TailscaleService: """Run a tailscale subcommand and return (returncode, stdout, stderr). Resolves the binary to an absolute path to guard against PATH hijacking. - Raises OSError if the binary cannot be found or launched. - Raises asyncio.TimeoutError if the subprocess exceeds the timeout. """ binary = shutil.which("tailscale") if not binary: @@ -138,8 +115,6 @@ class TailscaleService: async def get_status(self) -> TailscaleStatus: """Query Tailscale status and return machine identity. - Runs: tailscale status --json - Returns TailscaleStatus(available=False) if the binary is missing, the daemon is not running, or any other error occurs. """ @@ -165,9 +140,6 @@ class TailscaleService: ) if returncode is None or returncode != 0: - # If the binary is present but the daemon socket is unreachable (e.g. - # Docker without the socket mount), log the actionable hint rather than - # just the opaque CLI stderr. self._log_docker_socket_hint() return TailscaleStatus( available=False, @@ -201,7 +173,6 @@ class TailscaleService: error="Tailscale not connected (no DNSName)", ) - # Split "myhost.tailnetname.ts.net" into hostname + tailnet_name parts = fqdn.split(".", 1) hostname = parts[0] tailnet_name = parts[1] if len(parts) > 1 else "" @@ -217,122 +188,6 @@ class TailscaleService: tailscale_ips=tailscale_ips, ) - async def provision_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool: - """Request a Let's Encrypt certificate for the given Tailscale FQDN. - - Runs: tailscale cert --cert-file --key-file - - Returns True on success, False on any error. - """ - if not _FQDN_RE.match(fqdn): - logger.warning("provision_cert: invalid FQDN %r, skipping", fqdn) - return False - - # Ensure the target directory exists before tailscale cert writes to it - cert_path.parent.mkdir(parents=True, exist_ok=True) - - logger.info("Provisioning Tailscale cert for %s -> %s", fqdn, cert_path) - try: - returncode, _, stderr = await self._run_tailscale( - "cert", - "--cert-file", - str(cert_path), - "--key-file", - str(key_path), - fqdn, - timeout=60.0, - ) - except OSError as e: - logger.warning("tailscale cert failed (OS error): %s", e) - return False - - if returncode is None or returncode != 0: - err_text = stderr.decode(errors="replace").strip() - if _HTTPS_DISABLED_RE.search(err_text): - logger.warning( - "Tailscale HTTPS certs are not enabled for this tailnet. " - "Visit https://login.tailscale.com/admin/dns and enable HTTPS. " - "Falling back to self-signed cert." - ) - else: - logger.warning("tailscale cert failed (exit %s): %s", returncode, err_text) - return False - - # Restrict private key permissions - try: - key_path.chmod(0o600) - except OSError as e: - logger.warning("Could not set key permissions on %s: %s", key_path, e) - - # Verify the files are readable by the current process — on bare-metal, the - # tailscale daemon or a prior sudo invocation may have left them root-owned. - if not os.access(cert_path, os.R_OK) or not os.access(key_path, os.R_OK): - logger.error( - "Tailscale cert files at %s are not readable by this process. " - "Fix with: sudo chown $(whoami):$(whoami) %s %s", - cert_path.parent, - cert_path, - key_path, - ) - return False - - logger.info("Tailscale cert provisioned: %s", cert_path) - return True - - def cert_needs_renewal(self, cert_path: Path, fqdn: str | None = None) -> bool: - """Check whether the certificate at cert_path needs to be renewed. - - Returns True if the file is absent, unreadable, expires within - TS_CERT_EXPIRY_THRESHOLD_DAYS days, or if fqdn is given and does not - appear in the certificate's Subject Alternative Names. - """ - if not cert_path.exists(): - return True - - try: - cert_pem = cert_path.read_bytes() - # The file may contain a full chain; load only the first PEM block - cert = x509.load_pem_x509_certificate(cert_pem) - now = datetime.now(timezone.utc) - days_remaining = (cert.not_valid_after_utc - now).days - if days_remaining < TS_CERT_EXPIRY_THRESHOLD_DAYS: - logger.info("Tailscale cert expires in %d days, renewal needed", days_remaining) - return True - - # Validate that the cert covers the requested FQDN (guards against stale - # cert after machine rename or tailnet migration). Case-insensitive per RFC 4343. - if fqdn: - try: - san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) - dns_names = san.value.get_values_for_type(x509.DNSName) - if fqdn.lower() not in {n.lower() for n in dns_names}: - logger.info( - "Tailscale cert SAN mismatch (cert has %s, need %s), renewal needed", - dns_names, - fqdn, - ) - return True - except x509.ExtensionNotFound: - logger.info("Tailscale cert has no SAN extension, renewal needed") - return True - - logger.debug("Tailscale cert valid for %d more days", days_remaining) - return False - except (OSError, ValueError) as e: - logger.warning("Could not read Tailscale cert %s: %s", cert_path, e) - return True - - async def ensure_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool: - """Ensure a fresh certificate exists at cert_path. - - Skips provisioning if the cert is present, not near expiry, and covers fqdn. - Returns True if a valid cert is now available. - """ - if not self.cert_needs_renewal(cert_path, fqdn=fqdn): - logger.debug("Tailscale cert is fresh, skipping provision") - return True - return await self.provision_cert(fqdn, cert_path, key_path) - # Module-level singleton — import this in other modules tailscale_service = TailscaleService() diff --git a/backend/tests/integration/test_virtual_printer_api.py b/backend/tests/integration/test_virtual_printer_api.py index f8e8c72bc..692770952 100644 --- a/backend/tests/integration/test_virtual_printer_api.py +++ b/backend/tests/integration/test_virtual_printer_api.py @@ -340,106 +340,47 @@ class TestVirtualPrinterAutoDispatchAPI: assert get_resp.json()["auto_dispatch"] is False -class TestVirtualPrinterTailscaleGuardAPI: - """Enabling Tailscale on a host without the binary must be rejected with 409.""" +class TestVirtualPrinterTailscaleToggleAPI: + """The Tailscale toggle is informational — toggling either way always succeeds. + + There used to be a 409 guard rejecting "enable" when the daemon was unreachable, + back when the toggle controlled LE cert provisioning. That path was removed: + the slicer's printer-MQTT trust validates against its bundled BBL CA, not the + system trust store, so even an LE cert wouldn't be accepted. The toggle now + only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is + irrelevant to whether the toggle can be flipped. + """ @pytest.mark.asyncio @pytest.mark.integration - async def test_enable_tailscale_rejected_when_binary_missing(self, async_client: AsyncClient): - """PUT tailscale_disabled=False must 409 when tailscale_service reports unavailable.""" - from backend.app.services.virtual_printer.tailscale import TailscaleStatus - + async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient): + """PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds.""" create_resp = await async_client.post( "/api/v1/virtual-printers", json={ - "name": "TestTailscaleGuard", + "name": "TestTailscaleToggle", "mode": "immediate", "access_code": "12345678", }, ) assert create_resp.status_code == 200 vp_id = create_resp.json()["id"] - # New VPs default to tailscale_disabled=True (opt-in). assert create_resp.json()["tailscale_disabled"] is True - mock_status = TailscaleStatus( - available=False, - hostname="", - tailnet_name="", - fqdn="", - error="tailscale binary not found", - ) with patch( "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status", - new=AsyncMock(return_value=mock_status), + new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")), ): - update_resp = await async_client.put( + enable_resp = await async_client.put( f"/api/v1/virtual-printers/{vp_id}", json={"tailscale_disabled": False}, ) - assert update_resp.status_code == 409 - assert update_resp.json()["detail"] == "tailscale_not_available" - - # Ensure the row was not modified. - get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}") - assert get_resp.json()["tailscale_disabled"] is True - - @pytest.mark.asyncio - @pytest.mark.integration - async def test_enable_tailscale_allowed_when_binary_present(self, async_client: AsyncClient): - """PUT tailscale_disabled=False succeeds when tailscale_service reports available.""" - from backend.app.services.virtual_printer.tailscale import TailscaleStatus - - create_resp = await async_client.post( - "/api/v1/virtual-printers", - json={ - "name": "TestTailscaleAllow", - "mode": "immediate", - "access_code": "12345678", - }, - ) - vp_id = create_resp.json()["id"] - - mock_status = TailscaleStatus( - available=True, - hostname="host", - tailnet_name="tail.ts.net", - fqdn="host.tail.ts.net", - error=None, - ) - with patch( - "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status", - new=AsyncMock(return_value=mock_status), - ): - update_resp = await async_client.put( - f"/api/v1/virtual-printers/{vp_id}", - json={"tailscale_disabled": False}, - ) - assert update_resp.status_code == 200 - assert update_resp.json()["tailscale_disabled"] is False - - @pytest.mark.asyncio - @pytest.mark.integration - async def test_disable_tailscale_skips_binary_check(self, async_client: AsyncClient): - """Disabling (tailscale_disabled=True) never calls tailscale_service — always allowed.""" - create_resp = await async_client.post( - "/api/v1/virtual-printers", - json={ - "name": "TestTailscaleDisableSkip", - "mode": "immediate", - "access_code": "12345678", - }, - ) - vp_id = create_resp.json()["id"] - - # get_status must not be called on the disable path. - with patch( - "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status", - new=AsyncMock(side_effect=AssertionError("get_status must not be called for disable")), - ): - update_resp = await async_client.put( + disable_resp = await async_client.put( f"/api/v1/virtual-printers/{vp_id}", json={"tailscale_disabled": True}, ) - assert update_resp.status_code == 200 - assert update_resp.json()["tailscale_disabled"] is True + + assert enable_resp.status_code == 200 + assert enable_resp.json()["tailscale_disabled"] is False + assert disable_resp.status_code == 200 + assert disable_resp.json()["tailscale_disabled"] is True diff --git a/backend/tests/unit/services/test_tailscale.py b/backend/tests/unit/services/test_tailscale.py index b3fe8b66d..734bba9b6 100644 --- a/backend/tests/unit/services/test_tailscale.py +++ b/backend/tests/unit/services/test_tailscale.py @@ -1,54 +1,19 @@ -"""Unit tests for TailscaleService and Tailscale-aware VirtualPrinterInstance.""" +"""Unit tests for TailscaleService — presence detection only. + +Cert provisioning was removed: BambuStudio's printer-MQTT trust path validates +against its bundled BBL CA, not the system trust store, so a Tailscale-issued +LE cert was rejected regardless of hostname/IP. The Tailscale toggle is now +informational (surfacing the host's Tailscale IP/FQDN to guide the user). +""" -import asyncio import json -from datetime import datetime, timedelta, timezone -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID - - -def _make_cert(tmp_path: Path, days_valid: int, fqdn: str | None = None) -> Path: - """Write a self-signed cert valid for days_valid days and return its path. - - If fqdn is provided the cert includes a SubjectAlternativeName DNS entry. - """ - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - now = datetime.now(timezone.utc) - builder = ( - x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")])) - .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")])) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now) - .not_valid_after(now + timedelta(days=days_valid)) - ) - if fqdn: - builder = builder.add_extension( - x509.SubjectAlternativeName([x509.DNSName(fqdn)]), - critical=False, - ) - cert = builder.sign(key, hashes.SHA256()) - path = tmp_path / "cert.crt" - path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - return path - - -# ============================================================================= -# TailscaleService tests -# ============================================================================= class TestTailscaleService: - """Tests for TailscaleService CLI wrapper.""" - - # -- get_status -- + """Tests for TailscaleService CLI wrapper — get_status only.""" @pytest.mark.asyncio async def test_get_status_binary_not_found(self): @@ -65,7 +30,7 @@ class TestTailscaleService: @pytest.mark.asyncio async def test_get_status_command_fails(self): - """Returns available=False when the tailscale status command exits non-zero.""" + """Returns available=False when `tailscale status` exits non-zero.""" from backend.app.services.virtual_printer.tailscale import TailscaleService svc = TailscaleService() @@ -104,726 +69,35 @@ class TestTailscaleService: assert status.tailnet_name == "example.ts.net" assert "100.1.2.3" in status.tailscale_ips - # -- provision_cert -- - @pytest.mark.asyncio - async def test_provision_cert_success(self, tmp_path): - """Returns True and forwards the correct arguments to _run_tailscale.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = tmp_path / "ts.crt" - key_path = tmp_path / "ts.key" - cert_path.write_text("fake-cert") - key_path.write_text("fake-key") - - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run: - result = await svc.provision_cert("myhost.ts.net", cert_path, key_path) - - assert result is True - called_args = mock_run.call_args[0] # positional args to _run_tailscale - assert "cert" in called_args - assert "--cert-file" in called_args - assert str(cert_path) in called_args - assert "myhost.ts.net" in called_args - - @pytest.mark.asyncio - async def test_provision_cert_failure(self, tmp_path): - """Returns False without raising when the tailscale cert command fails.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"not logged in")): - result = await svc.provision_cert("myhost.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key") - - assert result is False - - # -- cert_needs_renewal -- - - def test_cert_needs_renewal_absent(self, tmp_path): - """Returns True when the cert file does not exist.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - assert svc.cert_needs_renewal(tmp_path / "nonexistent.crt") is True - - def test_cert_needs_renewal_fresh(self, tmp_path): - """Returns False when the cert has more than the threshold days remaining.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60) - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path) is False - - def test_cert_needs_renewal_expiring(self, tmp_path): - """Returns True when the cert is within the renewal threshold.""" - from backend.app.services.virtual_printer.tailscale import ( - TS_CERT_EXPIRY_THRESHOLD_DAYS, - TailscaleService, - ) - - cert_path = _make_cert(tmp_path, days_valid=TS_CERT_EXPIRY_THRESHOLD_DAYS - 1) - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path) is True - - # -- ensure_cert -- - - @pytest.mark.asyncio - async def test_ensure_cert_skips_provision_when_fresh(self, tmp_path): - """Does not call provision_cert when the existing cert is still fresh.""" + async def test_get_status_empty_dnsname(self): + """Returns available=False when Tailscale daemon reports no DNSName (not connected).""" from backend.app.services.virtual_printer.tailscale import TailscaleService + payload = {"Self": {"DNSName": "", "TailscaleIPs": []}} svc = TailscaleService() 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("shutil.which", return_value="/usr/bin/tailscale"), patch.object( - instance._cert_service, - "use_tailscale_cert", - new_callable=AsyncMock, - return_value=(ts_cert, ts_key), + svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"") ), ): - cert_path, key_path, advertise = await instance._resolve_cert_and_advertise() + status = await svc.get_status() - assert cert_path == ts_cert - assert key_path == ts_key - assert advertise == "myhost.example.ts.net" - assert instance.tailscale_fqdn == "myhost.example.ts.net" + assert status.available is False + assert "no DNSName" in (status.error or "") @pytest.mark.asyncio - async def test_resolve_falls_back_to_selfsigned(self, instance, tmp_path): - """Falls back to self-signed cert and IP string when Tailscale is absent.""" - from backend.app.services.virtual_printer.tailscale import TailscaleStatus - - self_cert = tmp_path / "cert.crt" - self_key = tmp_path / "cert.key" - - mock_ts = MagicMock() - mock_ts.get_status = AsyncMock( - return_value=TailscaleStatus( - available=False, - hostname="", - tailnet_name="", - fqdn="", - error="tailscale binary not found", - ) - ) - - with ( - patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts), - patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)), - ): - cert_path, key_path, advertise = await instance._resolve_cert_and_advertise() - - assert cert_path == self_cert - assert key_path == self_key - assert instance.tailscale_fqdn is None - assert isinstance(advertise, str) - - def test_tailscale_fqdn_in_status_when_set(self, instance): - """get_status() includes tailscale_fqdn when it is set.""" - instance.tailscale_fqdn = "myhost.example.ts.net" - status = instance.get_status() - assert status.get("tailscale_fqdn") == "myhost.example.ts.net" - - def test_tailscale_fqdn_absent_from_status_when_none(self, instance): - """get_status() omits the tailscale_fqdn key when tailscale_fqdn is None.""" - instance.tailscale_fqdn = None - status = instance.get_status() - assert "tailscale_fqdn" not in status - - @pytest.mark.asyncio - async def test_tailscale_disabled_skips_tailscale_entirely(self, tmp_path): - """When tailscale_disabled=True, Tailscale is never queried and self-signed cert is used.""" - from backend.app.services.virtual_printer.manager import VirtualPrinterInstance - - self_cert = tmp_path / "cert.crt" - self_key = tmp_path / "cert.key" - - instance = VirtualPrinterInstance( - vp_id=2, - name="NoTailscale", - mode="immediate", - model="C11", - access_code="12345678", - serial_suffix="391800001", - tailscale_disabled=True, - base_dir=tmp_path, - ) - - mock_ts = MagicMock() - mock_ts.get_status = AsyncMock() - - with ( - patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts), - patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)), - ): - cert_path, key_path, advertise = await instance._resolve_cert_and_advertise() - - # Tailscale must never have been queried - mock_ts.get_status.assert_not_called() - assert cert_path == self_cert - assert key_path == self_key - assert instance.tailscale_fqdn is None - - @pytest.mark.asyncio - async def test_tailscale_enabled_explicitly_queries_tailscale(self, instance): - """When tailscale_disabled=False (user opted in), Tailscale is queried as usual.""" - from backend.app.services.virtual_printer.tailscale import TailscaleStatus - - mock_ts = MagicMock() - mock_ts.get_status = AsyncMock( - return_value=TailscaleStatus( - available=False, - hostname="", - tailnet_name="", - fqdn="", - error="not connected", - ) - ) - - self_cert = instance.cert_dir / "cert.crt" - self_key = instance.cert_dir / "cert.key" - - with ( - patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts), - patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)), - ): - await instance._resolve_cert_and_advertise() - - mock_ts.get_status.assert_called_once() - - -# ============================================================================= -# cert_needs_renewal — FQDN SAN validation, exception narrowing, FQDN regex -# ============================================================================= - - -class TestCertNeedsRenewalExtended: - """Extended tests for cert_needs_renewal() covering new FQDN and exception logic.""" - - def test_fqdn_match_fresh_cert_not_renewed(self, tmp_path): - """Fresh cert whose SAN matches the requested FQDN is not renewed.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - fqdn = "myhost.example.ts.net" - cert_path = _make_cert(tmp_path, days_valid=60, fqdn=fqdn) - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path, fqdn=fqdn) is False - - def test_fqdn_mismatch_triggers_renewal(self, tmp_path): - """Fresh cert whose SAN does NOT match the requested FQDN triggers renewal.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60, fqdn="oldhost.example.ts.net") - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path, fqdn="newhost.example.ts.net") is True - - def test_cert_without_san_triggers_renewal_when_fqdn_given(self, tmp_path): - """Cert with no SAN extension triggers renewal when an FQDN is requested.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None) - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path, fqdn="myhost.example.ts.net") is True - - def test_fqdn_not_checked_when_none(self, tmp_path): - """Fresh cert with no SAN is valid when no FQDN is requested (backward-compat).""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None) - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path, fqdn=None) is False - - def test_narrow_exception_oserror_triggers_renewal(self, tmp_path): - """OSError while reading the cert file triggers renewal.""" - from unittest.mock import patch - - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60) - svc = TailscaleService() - with patch("pathlib.Path.read_bytes", side_effect=OSError("permission denied")): - assert svc.cert_needs_renewal(cert_path) is True - - def test_narrow_exception_valueerror_triggers_renewal(self, tmp_path): - """ValueError (bad PEM data) while loading the cert triggers renewal.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = tmp_path / "bad.crt" - cert_path.write_bytes(b"not a valid pem") - svc = TailscaleService() - assert svc.cert_needs_renewal(cert_path) is True - - def test_programming_error_propagates(self, tmp_path): - """Unexpected exceptions (not OSError/ValueError) are NOT silently swallowed.""" - from unittest.mock import patch - - from backend.app.services.virtual_printer.tailscale import TailscaleService - - cert_path = _make_cert(tmp_path, days_valid=60) - svc = TailscaleService() - with ( - patch("pathlib.Path.read_bytes", side_effect=RuntimeError("unexpected")), - pytest.raises(RuntimeError, match="unexpected"), - ): - svc.cert_needs_renewal(cert_path) - - -class TestProvisionCertFQDNValidation: - """Tests for FQDN input validation in provision_cert().""" - - @pytest.mark.asyncio - async def test_invalid_fqdn_rejected_without_subprocess(self, tmp_path): - """provision_cert() returns False immediately for an invalid FQDN.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run: - result = await svc.provision_cert("../evil", tmp_path / "c.crt", tmp_path / "k.key") - - assert result is False - mock_run.assert_not_called() - - @pytest.mark.asyncio - async def test_single_label_fqdn_rejected(self, tmp_path): - """A hostname without dots (no tailnet) is rejected.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run: - result = await svc.provision_cert("justhostname", tmp_path / "c.crt", tmp_path / "k.key") - - assert result is False - mock_run.assert_not_called() - - @pytest.mark.asyncio - async def test_valid_fqdn_passes_to_subprocess(self, tmp_path): - """A valid FQDN is forwarded to _run_tailscale.""" - from backend.app.services.virtual_printer.tailscale import TailscaleService - - key_path = tmp_path / "k.key" - cert_path = tmp_path / "c.crt" - cert_path.write_text("fake-cert") - key_path.write_text("fake") - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run: - result = await svc.provision_cert("myhost.example.ts.net", cert_path, key_path) - - assert result is True - assert "myhost.example.ts.net" in mock_run.call_args[0] - - -# ============================================================================= -# Additional coverage: OSError path, JSON error, CertificateService wrapper -# ============================================================================= - - -class TestProvisionCertOSError: - """provision_cert returns False when _run_tailscale raises OSError.""" - - @pytest.mark.asyncio - async def test_oserror_returns_false(self, tmp_path): - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, side_effect=OSError("no binary")): - result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key") - - assert result is False - - -class TestProvisionCertHTTPSDisabled: - """provision_cert logs an actionable message when the tailnet has HTTPS certs disabled.""" - - @pytest.mark.asyncio - async def test_https_disabled_logs_admin_url(self, tmp_path, caplog): - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - disabled_stderr = b"HTTPS cert generation is disabled for this tailnet" - with ( - patch.object( - svc, - "_run_tailscale", - new_callable=AsyncMock, - return_value=(1, b"", disabled_stderr), - ), - caplog.at_level("WARNING"), - ): - result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key") - - assert result is False - assert "login.tailscale.com/admin/dns" in caplog.text - - @pytest.mark.asyncio - async def test_generic_error_logs_exit_code(self, tmp_path, caplog): - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - with ( - patch.object( - svc, - "_run_tailscale", - new_callable=AsyncMock, - return_value=(1, b"", b"some other error"), - ), - caplog.at_level("WARNING"), - ): - result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key") - - assert result is False - assert "exit 1" in caplog.text - assert "login.tailscale.com" not in caplog.text - - -class TestProvisionCertReadability: - """provision_cert returns False when cert files are not readable after provisioning.""" - - @pytest.mark.asyncio - async def test_unreadable_key_returns_false(self, tmp_path, caplog): - from backend.app.services.virtual_printer.tailscale import TailscaleService - - svc = TailscaleService() - cert_path = tmp_path / "c.crt" - key_path = tmp_path / "k.key" - with ( - patch.object( - svc, - "_run_tailscale", - new_callable=AsyncMock, - return_value=(0, b"", 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 - - -class TestGetStatusJSONError: - """get_status returns available=False when tailscale outputs non-JSON.""" - - @pytest.mark.asyncio - async def test_bad_json_returns_unavailable(self): + async def test_get_status_malformed_json(self): + """Returns available=False with a parse-error reason when stdout is not JSON.""" from backend.app.services.virtual_printer.tailscale import TailscaleService svc = TailscaleService() with ( patch("shutil.which", return_value="/usr/bin/tailscale"), - patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not json {{", b"")), + patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not-json{", b"")), ): status = await svc.get_status() assert status.available is False - assert status.error is not None - assert "JSON" in status.error - - -class TestUseTailscaleCertWrapper: - """CertificateService.use_tailscale_cert delegates to tailscale_svc.ensure_cert.""" - - @pytest.mark.asyncio - async def test_returns_paths_on_success(self, tmp_path): - from backend.app.services.virtual_printer.certificate import CertificateService - - svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001") - mock_ts = MagicMock() - mock_ts.ensure_cert = AsyncMock(return_value=True) - - result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts) - - assert result == (svc.ts_cert_path, svc.ts_key_path) - mock_ts.ensure_cert.assert_called_once_with("myhost.ts.net", svc.ts_cert_path, svc.ts_key_path) - - @pytest.mark.asyncio - async def test_returns_none_on_failure(self, tmp_path): - from backend.app.services.virtual_printer.certificate import CertificateService - - svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001") - mock_ts = MagicMock() - mock_ts.ensure_cert = AsyncMock(return_value=False) - - result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts) - - assert result is None - - -# ============================================================================= -# _cert_renewal_loop tests -# ============================================================================= - - -class TestCertRenewalLoop: - """Tests for VirtualPrinterInstance._cert_renewal_loop.""" - - @pytest.fixture - def instance(self, tmp_path): - from backend.app.services.virtual_printer.manager import VirtualPrinterInstance - - return VirtualPrinterInstance( - vp_id=99, - name="RenewalTestPrinter", - mode="immediate", - model="C11", - access_code="12345678", - serial_suffix="391800001", - base_dir=tmp_path, - ) - - @pytest.mark.asyncio - async def test_loop_skips_when_fqdn_not_set(self, instance): - """Loop does nothing when tailscale_fqdn is None — just sleeps.""" - instance.tailscale_fqdn = None - sleep_call_count = [0] - - async def fast_sleep(n): - sleep_call_count[0] += 1 - if sleep_call_count[0] >= 2: - raise asyncio.CancelledError() - - with ( - patch("asyncio.sleep", side_effect=fast_sleep), - patch.object(instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock) as mock_use, - ): - task = asyncio.create_task(instance._cert_renewal_loop()) - try: - await task - except asyncio.CancelledError: - pass - - mock_use.assert_not_called() - - @pytest.mark.asyncio - async def test_loop_calls_renewal_when_cert_needs_it(self, instance): - """Loop calls use_tailscale_cert when fqdn is set and cert needs renewal.""" - instance.tailscale_fqdn = "myhost.ts.net" - - async def fast_sleep(n): - raise asyncio.CancelledError() - - with ( - patch("asyncio.sleep", side_effect=fast_sleep), - patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts, - patch.object( - instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock, return_value=None - ) as mock_use, - ): - mock_ts.cert_needs_renewal.return_value = True - task = asyncio.create_task(instance._cert_renewal_loop()) - try: - await task - except asyncio.CancelledError: - pass - - mock_use.assert_called_once() - - @pytest.mark.asyncio - async def test_loop_cancelled_error_exits_cleanly(self, instance): - """CancelledError in the sleep breaks the loop without raising.""" - instance.tailscale_fqdn = None - - async def immediate_cancel(n): - raise asyncio.CancelledError() - - with patch("asyncio.sleep", side_effect=immediate_cancel): - task = asyncio.create_task(instance._cert_renewal_loop()) - await task # must complete without raising - - @pytest.mark.asyncio - async def test_loop_backs_off_on_unexpected_error(self, instance): - """Unexpected exceptions are logged and the loop backs off with a 3600 s sleep.""" - instance.tailscale_fqdn = "myhost.ts.net" - sleep_args: list[float] = [] - - async def tracking_sleep(n): - sleep_args.append(n) - if len(sleep_args) >= 2: - raise asyncio.CancelledError() - - with ( - patch("asyncio.sleep", side_effect=tracking_sleep), - patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts, - ): - mock_ts.cert_needs_renewal.side_effect = RuntimeError("unexpected db error") - task = asyncio.create_task(instance._cert_renewal_loop()) - try: - await task - except asyncio.CancelledError: - pass - - assert 3600 in sleep_args - - @pytest.mark.asyncio - async def test_loop_schedules_restart_after_renewal(self, instance): - """When a renewal succeeds, a restart task is scheduled and the loop exits.""" - instance.tailscale_fqdn = "myhost.ts.net" - restart_scheduled = [False] - _real_create_task = asyncio.create_task - - def tracking_create_task(coro, *, name=None): - if name and "cert_restart" in name: - restart_scheduled[0] = True - coro.close() - # Return a dummy completed task - fut = asyncio.get_event_loop().create_future() - fut.set_result(None) - return fut - return _real_create_task(coro, name=name) - - with ( - patch("asyncio.sleep", new_callable=AsyncMock), - patch.object(asyncio, "create_task", side_effect=tracking_create_task), - patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts, - patch.object( - instance._cert_service, - "use_tailscale_cert", - new_callable=AsyncMock, - return_value=(instance._cert_service.ts_cert_path, instance._cert_service.ts_key_path), - ), - ): - mock_ts.cert_needs_renewal.return_value = True - # Run the loop directly; it exits via break after scheduling the restart - task = _real_create_task(instance._cert_renewal_loop()) - await task - - assert restart_scheduled[0] is True - - -class TestCancelRestartTaskSelfAwait: - """Regression: _cancel_restart_task must not await the current task. - - stop_server() / stop_proxy() are called from inside _restart_for_cert_renewal, - which runs AS _cert_restart_task. Cancelling+awaiting self would flag a - CancelledError on the next `await`, tearing down the old listeners but - never letting start_server run — the VP would stay on the old/expired cert - until the process restarts. - """ - - def _make_instance(self, tmp_path): - from backend.app.services.virtual_printer.manager import VirtualPrinterInstance - - return VirtualPrinterInstance( - vp_id=1, - name="TestVP", - mode="immediate", - model="C11", - access_code="12345678", - serial_suffix="391800001", - tailscale_disabled=False, - base_dir=tmp_path, - ) - - @pytest.mark.asyncio - async def test_cancel_from_inside_own_task_does_not_cancel_self(self, tmp_path): - """When _cancel_restart_task is called from inside the restart task itself, - it clears the reference without cancelling — subsequent awaits must succeed.""" - instance = self._make_instance(tmp_path) - completed_to_end = [False] - - async def fake_restart(): - # Simulate stop_server calling _cancel_restart_task from inside the restart task. - await instance._cancel_restart_task() - # If _cancel_restart_task had self-awaited, the next `await` would raise - # CancelledError and this line would never be reached. - await asyncio.sleep(0) - completed_to_end[0] = True - - task = asyncio.create_task(fake_restart(), name="cert_restart") - instance._cert_restart_task = task - await task - assert completed_to_end[0] is True - assert instance._cert_restart_task is None - - @pytest.mark.asyncio - async def test_cancel_from_outside_still_cancels_and_awaits(self, tmp_path): - """Non-self callers must retain the original cancel-and-await behaviour.""" - instance = self._make_instance(tmp_path) - started = asyncio.Event() - - async def long_restart(): - started.set() - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - raise - - task = asyncio.create_task(long_restart(), name="cert_restart") - instance._cert_restart_task = task - await started.wait() - # Cancel from an outside coroutine — this should actually cancel the task. - await instance._cancel_restart_task() - assert task.cancelled() - assert instance._cert_restart_task is None + assert "JSON parse error" in (status.error or "") diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 4fb4abbfc..213d4b39d 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -926,8 +926,13 @@ class TestVirtualPrinterManager: mock_remove.assert_called_once_with(1) @pytest.mark.asyncio - async def test_sync_from_db_restarts_on_tailscale_disabled_change(self, manager, tmp_path): - """VP restarts when tailscale_disabled flips from False to True.""" + async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path): + """Flipping tailscale_disabled is purely informational — must NOT trigger a restart. + + Cert provisioning was removed; the toggle only governs whether the VP card surfaces + the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing + it through sync_from_db should leave any running instance untouched. + """ from backend.app.services.virtual_printer.manager import VirtualPrinterInstance inst = VirtualPrinterInstance( @@ -947,14 +952,9 @@ class TestVirtualPrinterManager: self._setup_sync_mocks(manager, [db_vp], tmp_path) with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: - with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: - mock_new = MagicMock() - mock_new.start_server = AsyncMock() - MockInst.return_value = mock_new + await manager.sync_from_db() - await manager.sync_from_db() - - mock_remove.assert_called_once_with(1) + mock_remove.assert_not_called() class TestFTPSession: diff --git a/frontend/src/__tests__/components/VirtualPrinterCard.test.tsx b/frontend/src/__tests__/components/VirtualPrinterCard.test.tsx index 65b75cad5..0ddc122d5 100644 --- a/frontend/src/__tests__/components/VirtualPrinterCard.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterCard.test.tsx @@ -19,6 +19,14 @@ vi.mock('../../api/client', () => ({ multiVirtualPrinterApi: { update: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue({}), + getTailscaleStatus: vi.fn().mockResolvedValue({ + available: false, + fqdn: '', + hostname: '', + tailnet_name: '', + tailscale_ips: [], + error: null, + }), }, api: { getSettings: vi.fn().mockResolvedValue({}), @@ -295,46 +303,27 @@ describe('VirtualPrinterCard - tailscale toggle', () => { }); }); - it('reverts toggle and shows a specific toast when backend rejects enable (tailscale_not_available)', async () => { - const user = userEvent.setup(); - const printer = createMockPrinter({ tailscale_disabled: true }); - vi.mocked(multiVirtualPrinterApi.update).mockRejectedValueOnce( - new Error('tailscale_not_available') - ); - - render(); - - await waitFor(() => { - expect(screen.getByText('Tailscale integration')).toBeInTheDocument(); - }); - - const title = screen.getByText('Tailscale integration'); - const section = title.closest('.flex.items-center.justify-between'); - const toggleButton = section!.querySelector('button') as HTMLButtonElement; - // Disabled state → dark-grey background on the track. - expect(toggleButton.className).toContain('bg-bambu-dark-tertiary'); - - await user.click(toggleButton); - - await waitFor(() => { - expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: false }); - }); - - // After the 409 revert, the toggle goes back to the dark-grey (disabled) state. - await waitFor(() => { - expect(toggleButton.className).toContain('bg-bambu-dark-tertiary'); - }); - }); }); describe('VirtualPrinterCard - Tailscale FQDN copy', () => { + const fqdn = 'test-host.tail1234.ts.net'; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter()); + // FQDN now comes from the host-level Tailscale status endpoint, not VP status. + // Tests in this block need the toggle to be ON (tailscale_disabled=false) so the + // useQuery actually fires and the FQDN row renders. + vi.mocked(multiVirtualPrinterApi.getTailscaleStatus).mockResolvedValue({ + available: true, + fqdn, + hostname: 'test-host', + tailnet_name: 'tail1234.ts.net', + tailscale_ips: ['100.64.0.1'], + error: null, + }); }); - const fqdn = 'test-host.tail1234.ts.net'; - function getCopyButton() { // The copy button is a - {/* 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. */}
- {printer.status?.tailscale_fqdn && ( + {tailscaleFqdn && ( - {printer.status.tailscale_fqdn} + + {tailscaleIp ? `${tailscaleIp} (${tailscaleFqdn})` : tailscaleFqdn} +