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

feat(virtual-printer): add Tailscale opt-out toggle (closes #701 point 3) (#1070)

* feat(virtual-printer): add Tailscale certificate provisioning
lietschaend 1 месяц назад
Родитель
Сommit
91a3d391ff

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

@@ -844,6 +844,7 @@ async def get_virtual_printer_settings(
     model = await get_setting(db, "virtual_printer_model")
     model = await get_setting(db, "virtual_printer_model")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
     remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
     remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
+    tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
 
 
     return {
     return {
         "enabled": enabled == "true" if enabled else False,
         "enabled": enabled == "true" if enabled else False,
@@ -852,6 +853,7 @@ async def get_virtual_printer_settings(
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
         "remote_interface_ip": remote_interface_ip or "",
+        "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else False,
         "status": virtual_printer_manager.get_status(),
         "status": virtual_printer_manager.get_status(),
     }
     }
 
 
@@ -864,6 +866,7 @@ async def update_virtual_printer_settings(
     model: str = None,
     model: str = None,
     target_printer_id: int = None,
     target_printer_id: int = None,
     remote_interface_ip: str = None,
     remote_interface_ip: str = None,
+    tailscale_disabled: bool = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
@@ -890,6 +893,8 @@ async def update_virtual_printer_settings(
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
     current_target_id = int(current_target_id_str) if current_target_id_str else None
     current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
     current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
+    current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
+    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else False
 
 
     # Apply updates
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
     new_enabled = enabled if enabled is not None else current_enabled
@@ -898,6 +903,7 @@ async def update_virtual_printer_settings(
     new_model = model if model is not None else current_model
     new_model = model if model is not None else current_model
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
     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
 
 
     # 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)
@@ -976,6 +982,18 @@ async def update_virtual_printer_settings(
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
     if remote_interface_ip is not None:
     if remote_interface_ip is not None:
         await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
         await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
+    if tailscale_disabled is not None:
+        await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+
+    # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
+    if tailscale_disabled is not None:
+        from backend.app.models.virtual_printer import VirtualPrinter as VPModel
+
+        vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
+        first_vp = vp_result.scalar_one_or_none()
+        if first_vp is not None:
+            first_vp.tailscale_disabled = new_ts_disabled
+
     await db.commit()
     await db.commit()
     db.expire_all()
     db.expire_all()
 
 

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

@@ -11,11 +11,24 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
 
 
+# Imported at module scope so tests can patch
+# backend.app.api.routes.virtual_printers.tailscale_service.
+from backend.app.services.virtual_printer.tailscale import tailscale_service
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
 router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
 
 
 
 
+class TailscaleStatusResponse(BaseModel):
+    available: bool
+    fqdn: str
+    hostname: str
+    tailnet_name: str
+    tailscale_ips: list[str]
+    error: str | None
+
+
 class VirtualPrinterCreate(BaseModel):
 class VirtualPrinterCreate(BaseModel):
     name: str = "Bambuddy"
     name: str = "Bambuddy"
     enabled: bool = False
     enabled: bool = False
@@ -38,6 +51,7 @@ class VirtualPrinterUpdate(BaseModel):
     auto_dispatch: bool | None = None
     auto_dispatch: bool | None = None
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
+    tailscale_disabled: bool | None = None
 
 
 
 
 def _resolve_printer_model(printer_model: str | None) -> str | None:
 def _resolve_printer_model(printer_model: str | None) -> str | None:
@@ -78,6 +92,7 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
         "auto_dispatch": vp.auto_dispatch,
         "auto_dispatch": vp.auto_dispatch,
         "bind_ip": vp.bind_ip,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "remote_interface_ip": vp.remote_interface_ip,
+        "tailscale_disabled": vp.tailscale_disabled,
         "position": vp.position,
         "position": vp.position,
         "status": status or {"running": False, "pending_files": 0},
         "status": status or {"running": False, "pending_files": 0},
     }
     }
@@ -215,6 +230,26 @@ async def create_virtual_printer(
     return _vp_to_dict(vp)
     return _vp_to_dict(vp)
 
 
 
 
+@router.get("/tailscale-status", response_model=TailscaleStatusResponse)
+async def get_tailscale_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+) -> TailscaleStatusResponse:
+    """Return current Tailscale availability and machine identity.
+
+    Used by the frontend to indicate whether virtual printer TLS is backed
+    by a trusted Let's Encrypt certificate or a self-signed CA.
+    """
+    status = await tailscale_service.get_status()
+    return TailscaleStatusResponse(
+        available=status.available,
+        fqdn=status.fqdn,
+        hostname=status.hostname,
+        tailnet_name=status.tailnet_name,
+        tailscale_ips=status.tailscale_ips,
+        error=status.error,
+    )
+
+
 @router.get("/{vp_id}")
 @router.get("/{vp_id}")
 async def get_virtual_printer(
 async def get_virtual_printer(
     vp_id: int,
     vp_id: int,
@@ -300,6 +335,8 @@ async def update_virtual_printer(
         vp.bind_ip = body.bind_ip
         vp.bind_ip = body.bind_ip
     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:
+        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
     if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
     if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:

+ 3 - 0
backend/app/core/database.py

@@ -480,6 +480,9 @@ async def run_migrations(conn):
     # Migration: Add wiki_url column to maintenance_types for documentation links
     # Migration: Add wiki_url column to maintenance_types for documentation links
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
 
 
+    # Migration: Add tailscale_disabled column to virtual_printers for user opt-out
+    await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 0")
+
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
 
 

+ 3 - 0
backend/app/models/virtual_printer.py

@@ -25,6 +25,9 @@ class VirtualPrinter(Base):
     )  # proxy mode
     )  # proxy mode
     bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
     bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
     remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
     remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
+    tailscale_disabled: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # opt out of Tailscale auto-detect
     serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
     serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
     position: Mapped[int] = mapped_column(Integer, default=0)
     position: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 35 - 2
backend/app/services/virtual_printer/certificate.py

@@ -132,7 +132,10 @@ class CertificateService:
                 encryption_algorithm=serialization.NoEncryption(),
                 encryption_algorithm=serialization.NoEncryption(),
             )
             )
         )
         )
-        self.ca_key_path.chmod(0o600)
+        try:
+            self.ca_key_path.chmod(0o600)
+        except OSError as e:
+            logger.warning("Could not set CA key permissions on %s: %s", self.ca_key_path, e)
         self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
         self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
 
 
         logger.info("Saved new CA certificate")
         logger.info("Saved new CA certificate")
@@ -311,7 +314,10 @@ class CertificateService:
                 encryption_algorithm=serialization.NoEncryption(),
                 encryption_algorithm=serialization.NoEncryption(),
             )
             )
         )
         )
-        self.key_path.chmod(0o600)
+        try:
+            self.key_path.chmod(0o600)
+        except OSError as e:
+            logger.warning("Could not set printer key permissions on %s: %s", self.key_path, e)
 
 
         # Write printer certificate (include CA cert in chain for full chain)
         # Write printer certificate (include CA cert in chain for full chain)
         cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(
         cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(
@@ -324,6 +330,33 @@ 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]:

+ 148 - 4
backend/app/services/virtual_printer/manager.py

@@ -16,6 +16,7 @@ from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 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
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -112,6 +113,7 @@ class VirtualPrinterInstance:
         auto_dispatch: bool = True,
         auto_dispatch: bool = True,
         bind_ip: str = "",
         bind_ip: str = "",
         remote_interface_ip: str = "",
         remote_interface_ip: str = "",
+        tailscale_disabled: bool = False,
         base_dir: Path,
         base_dir: Path,
         session_factory: Callable | None = None,
         session_factory: Callable | None = None,
     ):
     ):
@@ -127,6 +129,7 @@ class VirtualPrinterInstance:
         self.auto_dispatch = auto_dispatch
         self.auto_dispatch = auto_dispatch
         self.bind_ip = bind_ip
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.remote_interface_ip = remote_interface_ip
+        self.tailscale_disabled = tailscale_disabled
         self._session_factory = session_factory
         self._session_factory = session_factory
 
 
         # Directories
         # Directories
@@ -146,6 +149,9 @@ 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] = {}
 
 
@@ -157,6 +163,8 @@ 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:
@@ -366,11 +374,129 @@ 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."""
+        if self._cert_restart_task and not self._cert_restart_task.done():
+            self._cert_restart_task.cancel()
+            try:
+                await self._cert_restart_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]:
+        """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.
+        """
+        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
+
     async def start_server(self) -> None:
     async def start_server(self) -> None:
         """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 = self.generate_certificates()
+        cert_path, key_path, advertise_addr = await 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):
@@ -432,12 +558,13 @@ class VirtualPrinterInstance:
             )
             )
         )
         )
 
 
-        # SSDP server
+        # SSDP server — advertise_addr is the Tailscale FQDN when available,
+        # otherwise the bind/remote IP (existing behaviour)
         self._ssdp = VirtualPrinterSSDPServer(
         self._ssdp = VirtualPrinterSSDPServer(
             name=self.name,
             name=self.name,
             serial=self.serial,
             serial=self.serial,
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
-            advertise_ip=self.remote_interface_ip or self.bind_ip or "",
+            advertise_ip=advertise_addr,
             bind_ip=bind_addr,
             bind_ip=bind_addr,
         )
         )
         self._tasks.append(
         self._tasks.append(
@@ -447,10 +574,16 @@ 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._ftp:
         if self._ftp:
             await self._ftp.stop()
             await self._ftp.stop()
             self._ftp = None
             self._ftp = None
@@ -469,7 +602,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 = self.generate_certificates()
+        cert_path, key_path, _ = await self._resolve_cert_and_advertise()
 
 
         self._proxy = SlicerProxyManager(
         self._proxy = SlicerProxyManager(
             target_host=self.target_printer_ip,
             target_host=self.target_printer_ip,
@@ -524,6 +657,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")
+
     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(
@@ -542,6 +679,8 @@ 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
@@ -570,6 +709,8 @@ 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
@@ -661,6 +802,7 @@ 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:
@@ -695,6 +837,7 @@ class VirtualPrinterManager:
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
+                    tailscale_disabled=vp.tailscale_disabled,
                     base_dir=self._base_dir,
                     base_dir=self._base_dir,
                     session_factory=self._session_factory,
                     session_factory=self._session_factory,
                 )
                 )
@@ -713,6 +856,7 @@ class VirtualPrinterManager:
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
+                    tailscale_disabled=vp.tailscale_disabled,
                     base_dir=self._base_dir,
                     base_dir=self._base_dir,
                     session_factory=self._session_factory,
                     session_factory=self._session_factory,
                 )
                 )

+ 326 - 0
backend/app/services/virtual_printer/tailscale.py

@@ -0,0 +1,326 @@
+"""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.
+"""
+
+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.
+_SUBPROCESS_ENV: dict[str, str] = {
+    k: v
+    for k, v in os.environ.items()
+    if k
+    in {
+        "PATH",
+        "HOME",
+        "USER",
+        "USERNAME",
+        "LOGNAME",
+        # Windows equivalents
+        "USERPROFILE",
+        "APPDATA",
+        "LOCALAPPDATA",
+        "PROGRAMFILES",
+        "PROGRAMFILES(X86)",
+        "SYSTEMROOT",
+        "WINDIR",
+        "COMPUTERNAME",
+        "TEMP",
+        "TMP",
+        # Linux XDG dirs used by tailscale for socket/config
+        "XDG_RUNTIME_DIR",
+        "XDG_CONFIG_HOME",
+    }
+}
+
+
+@dataclass
+class TailscaleStatus:
+    """Runtime Tailscale availability and identity."""
+
+    available: bool
+    hostname: str  # "myhost"
+    tailnet_name: str  # "tailnetname.ts.net"
+    fqdn: str  # "myhost.tailnetname.ts.net"
+    tailscale_ips: list[str] = field(default_factory=list)
+    error: str | None = None
+
+
+class TailscaleService:
+    """Wraps Tailscale CLI commands for certificate provisioning.
+
+    All methods are safe to call when Tailscale is absent — they return
+    sensible defaults and never raise exceptions.
+    """
+
+    _docker_hint_logged: bool = False
+
+    @classmethod
+    def _log_docker_socket_hint(cls) -> None:
+        """Log a one-time hint when running in Docker without the Tailscale socket mounted."""
+        if cls._docker_hint_logged:
+            return
+        if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
+            logger.info(
+                "Running in Docker but Tailscale socket not found. "
+                "Mount /var/run/tailscale/tailscaled.sock to enable Tailscale."
+            )
+            cls._docker_hint_logged = True
+
+    async def _run_tailscale(self, *args: str, timeout: float = 30.0) -> tuple[int | None, bytes, bytes]:
+        """Run a tailscale subcommand and return (returncode, stdout, stderr).
+
+        Resolves the binary to an absolute path to guard against PATH hijacking.
+        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:
+            raise OSError("tailscale binary not found")
+        process = await asyncio.create_subprocess_exec(
+            binary,
+            *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+            env=_SUBPROCESS_ENV,
+        )
+        try:
+            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+        except asyncio.TimeoutError:
+            process.kill()
+            await process.wait()
+            raise
+        return process.returncode, stdout, stderr
+
+    async def get_status(self) -> TailscaleStatus:
+        """Query Tailscale status and return machine identity.
+
+        Runs: tailscale status --json
+
+        Returns TailscaleStatus(available=False) if the binary is missing,
+        the daemon is not running, or any other error occurs.
+        """
+        if not shutil.which("tailscale"):
+            self._log_docker_socket_hint()
+            return TailscaleStatus(
+                available=False,
+                hostname="",
+                tailnet_name="",
+                fqdn="",
+                error="tailscale binary not found",
+            )
+
+        try:
+            returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
+        except OSError as e:
+            return TailscaleStatus(
+                available=False,
+                hostname="",
+                tailnet_name="",
+                fqdn="",
+                error=str(e),
+            )
+
+        if returncode is None or returncode != 0:
+            return TailscaleStatus(
+                available=False,
+                hostname="",
+                tailnet_name="",
+                fqdn="",
+                error=stderr.decode(errors="replace").strip(),
+            )
+
+        try:
+            data = json.loads(stdout)
+        except json.JSONDecodeError as e:
+            return TailscaleStatus(
+                available=False,
+                hostname="",
+                tailnet_name="",
+                fqdn="",
+                error=f"JSON parse error: {e}",
+            )
+
+        self_info = data.get("Self", {})
+
+        # DNSName includes trailing dot: "myhost.tailnetname.ts.net."
+        fqdn = self_info.get("DNSName", "").rstrip(".")
+        if not fqdn:
+            return TailscaleStatus(
+                available=False,
+                hostname="",
+                tailnet_name="",
+                fqdn="",
+                error="Tailscale not connected (no DNSName)",
+            )
+
+        # 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 ""
+
+        tailscale_ips = self_info.get("TailscaleIPs", [])
+
+        logger.debug("Tailscale available: fqdn=%s, ips=%s", fqdn, tailscale_ips)
+        return TailscaleStatus(
+            available=True,
+            hostname=hostname,
+            tailnet_name=tailnet_name,
+            fqdn=fqdn,
+            tailscale_ips=tailscale_ips,
+        )
+
+    async def provision_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
+        """Request a Let's Encrypt certificate for the given Tailscale FQDN.
+
+        Runs: tailscale cert --cert-file <cert_path> --key-file <key_path> <fqdn>
+
+        Returns True on success, False on any error.
+        """
+        if not _FQDN_RE.match(fqdn):
+            logger.warning("provision_cert: invalid FQDN %r, skipping", fqdn)
+            return False
+
+        # Ensure the target directory exists before tailscale cert writes to it
+        cert_path.parent.mkdir(parents=True, exist_ok=True)
+
+        logger.info("Provisioning Tailscale cert for %s -> %s", fqdn, cert_path)
+        try:
+            returncode, _, stderr = await self._run_tailscale(
+                "cert",
+                "--cert-file",
+                str(cert_path),
+                "--key-file",
+                str(key_path),
+                fqdn,
+                timeout=60.0,
+            )
+        except OSError as e:
+            logger.warning("tailscale cert failed (OS error): %s", e)
+            return False
+
+        if returncode is None or returncode != 0:
+            err_text = stderr.decode(errors="replace").strip()
+            if _HTTPS_DISABLED_RE.search(err_text):
+                logger.warning(
+                    "Tailscale HTTPS certs are not enabled for this tailnet. "
+                    "Visit https://login.tailscale.com/admin/dns and enable HTTPS. "
+                    "Falling back to self-signed cert."
+                )
+            else:
+                logger.warning("tailscale cert failed (exit %s): %s", returncode, err_text)
+            return False
+
+        # Restrict private key permissions
+        try:
+            key_path.chmod(0o600)
+        except OSError as e:
+            logger.warning("Could not set key permissions on %s: %s", key_path, e)
+
+        # Verify the files are readable by the current process — on bare-metal, the
+        # tailscale daemon or a prior sudo invocation may have left them root-owned.
+        if not os.access(cert_path, os.R_OK) or not os.access(key_path, os.R_OK):
+            logger.error(
+                "Tailscale cert files at %s are not readable by this process. "
+                "Fix with: sudo chown $(whoami):$(whoami) %s %s",
+                cert_path.parent,
+                cert_path,
+                key_path,
+            )
+            return False
+
+        logger.info("Tailscale cert provisioned: %s", cert_path)
+        return True
+
+    def cert_needs_renewal(self, cert_path: Path, fqdn: str | None = None) -> bool:
+        """Check whether the certificate at cert_path needs to be renewed.
+
+        Returns True if the file is absent, unreadable, expires within
+        TS_CERT_EXPIRY_THRESHOLD_DAYS days, or if fqdn is given and does not
+        appear in the certificate's Subject Alternative Names.
+        """
+        if not cert_path.exists():
+            return True
+
+        try:
+            cert_pem = cert_path.read_bytes()
+            # The file may contain a full chain; load only the first PEM block
+            cert = x509.load_pem_x509_certificate(cert_pem)
+            now = datetime.now(timezone.utc)
+            days_remaining = (cert.not_valid_after_utc - now).days
+            if days_remaining < TS_CERT_EXPIRY_THRESHOLD_DAYS:
+                logger.info("Tailscale cert expires in %d days, renewal needed", days_remaining)
+                return True
+
+            # Validate that the cert covers the requested FQDN (guards against stale
+            # cert after machine rename or tailnet migration). Case-insensitive per RFC 4343.
+            if fqdn:
+                try:
+                    san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
+                    dns_names = san.value.get_values_for_type(x509.DNSName)
+                    if fqdn.lower() not in {n.lower() for n in dns_names}:
+                        logger.info(
+                            "Tailscale cert SAN mismatch (cert has %s, need %s), renewal needed",
+                            dns_names,
+                            fqdn,
+                        )
+                        return True
+                except x509.ExtensionNotFound:
+                    logger.info("Tailscale cert has no SAN extension, renewal needed")
+                    return True
+
+            logger.debug("Tailscale cert valid for %d more days", days_remaining)
+            return False
+        except (OSError, ValueError) as e:
+            logger.warning("Could not read Tailscale cert %s: %s", cert_path, e)
+            return True
+
+    async def ensure_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
+        """Ensure a fresh certificate exists at cert_path.
+
+        Skips provisioning if the cert is present, not near expiry, and covers fqdn.
+        Returns True if a valid cert is now available.
+        """
+        if not self.cert_needs_renewal(cert_path, fqdn=fqdn):
+            logger.debug("Tailscale cert is fresh, skipping provision")
+            return True
+        return await self.provision_cert(fqdn, cert_path, key_path)
+
+
+# Module-level singleton — import this in other modules
+tailscale_service = TailscaleService()

+ 61 - 0
backend/tests/integration/test_tailscale_api.py

@@ -0,0 +1,61 @@
+"""Integration tests for GET /api/v1/virtual-printers/tailscale-status."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestTailscaleStatusAPI:
+    """Tests for the tailscale-status endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tailscale_status_available(self, async_client: AsyncClient):
+        """Returns 200 with available=true when Tailscale is connected."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
+
+        mock_status = 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.api.routes.virtual_printers.tailscale_service") as mock_svc:
+            mock_svc.get_status = AsyncMock(return_value=mock_status)
+            response = await async_client.get("/api/v1/virtual-printers/tailscale-status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["available"] is True
+        assert data["fqdn"] == "myhost.example.ts.net"
+        assert data["hostname"] == "myhost"
+        assert data["tailnet_name"] == "example.ts.net"
+        assert "100.1.2.3" in data["tailscale_ips"]
+        assert data["error"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tailscale_status_unavailable(self, async_client: AsyncClient):
+        """Returns 200 with available=false and error message when Tailscale is absent."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
+
+        mock_status = TailscaleStatus(
+            available=False,
+            hostname="",
+            tailnet_name="",
+            fqdn="",
+            error="tailscale binary not found",
+        )
+
+        with patch("backend.app.api.routes.virtual_printers.tailscale_service") as mock_svc:
+            mock_svc.get_status = AsyncMock(return_value=mock_status)
+            response = await async_client.get("/api/v1/virtual-printers/tailscale-status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["available"] is False
+        assert data["fqdn"] == ""
+        assert data["error"] == "tailscale binary not found"

+ 759 - 0
backend/tests/unit/services/test_tailscale.py

@@ -0,0 +1,759 @@
+"""Unit tests for TailscaleService and Tailscale-aware VirtualPrinterInstance."""
+
+import asyncio
+import json
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, 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 --
+
+    @pytest.mark.asyncio
+    async def test_get_status_binary_not_found(self):
+        """Returns available=False when the tailscale binary is absent from PATH."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        svc = TailscaleService()
+        with patch("shutil.which", return_value=None):
+            status = await svc.get_status()
+
+        assert status.available is False
+        assert status.error is not None
+        assert "not found" in status.error
+
+    @pytest.mark.asyncio
+    async def test_get_status_command_fails(self):
+        """Returns available=False when the tailscale status command exits non-zero."""
+        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=(1, b"", b"permission denied")),
+        ):
+            status = await svc.get_status()
+
+        assert status.available is False
+        assert "permission denied" in (status.error or "")
+
+    @pytest.mark.asyncio
+    async def test_get_status_success(self):
+        """Parses FQDN, hostname, tailnet_name, and IP list from JSON output."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        payload = {
+            "Self": {
+                "DNSName": "myhost.example.ts.net.",
+                "TailscaleIPs": ["100.1.2.3", "fd7a::1"],
+            }
+        }
+        svc = TailscaleService()
+        with (
+            patch("shutil.which", return_value="/usr/bin/tailscale"),
+            patch.object(
+                svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
+            ),
+        ):
+            status = await svc.get_status()
+
+        assert status.available is True
+        assert status.fqdn == "myhost.example.ts.net"
+        assert status.hostname == "myhost"
+        assert status.tailnet_name == "example.ts.net"
+        assert "100.1.2.3" in status.tailscale_ips
+
+    # -- provision_cert --
+
+    @pytest.mark.asyncio
+    async def test_provision_cert_success(self, tmp_path):
+        """Returns True and forwards the correct arguments to _run_tailscale."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        cert_path = tmp_path / "ts.crt"
+        key_path = tmp_path / "ts.key"
+        cert_path.write_text("fake-cert")
+        key_path.write_text("fake-key")
+
+        svc = TailscaleService()
+        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
+            result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
+
+        assert result is True
+        called_args = mock_run.call_args[0]  # positional args to _run_tailscale
+        assert "cert" in called_args
+        assert "--cert-file" in called_args
+        assert str(cert_path) in called_args
+        assert "myhost.ts.net" in called_args
+
+    @pytest.mark.asyncio
+    async def test_provision_cert_failure(self, tmp_path):
+        """Returns False without raising when the tailscale cert command fails."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        svc = TailscaleService()
+        with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"not logged in")):
+            result = await svc.provision_cert("myhost.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
+
+        assert result is False
+
+    # -- cert_needs_renewal --
+
+    def test_cert_needs_renewal_absent(self, tmp_path):
+        """Returns True when the cert file does not exist."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        svc = TailscaleService()
+        assert svc.cert_needs_renewal(tmp_path / "nonexistent.crt") is True
+
+    def test_cert_needs_renewal_fresh(self, tmp_path):
+        """Returns False when the cert has more than the threshold days remaining."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        cert_path = _make_cert(tmp_path, days_valid=60)
+        svc = TailscaleService()
+        assert svc.cert_needs_renewal(cert_path) is False
+
+    def test_cert_needs_renewal_expiring(self, tmp_path):
+        """Returns True when the cert is within the renewal threshold."""
+        from backend.app.services.virtual_printer.tailscale import (
+            TS_CERT_EXPIRY_THRESHOLD_DAYS,
+            TailscaleService,
+        )
+
+        cert_path = _make_cert(tmp_path, days_valid=TS_CERT_EXPIRY_THRESHOLD_DAYS - 1)
+        svc = TailscaleService()
+        assert svc.cert_needs_renewal(cert_path) is True
+
+    # -- ensure_cert --
+
+    @pytest.mark.asyncio
+    async def test_ensure_cert_skips_provision_when_fresh(self, tmp_path):
+        """Does not call provision_cert when the existing cert is still fresh."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        svc = TailscaleService()
+        with (
+            patch.object(svc, "cert_needs_renewal", return_value=False),
+            patch.object(svc, "provision_cert", new_callable=AsyncMock) as mock_prov,
+        ):
+            result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
+
+        assert result is True
+        mock_prov.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_ensure_cert_provisions_when_absent(self, tmp_path):
+        """Calls provision_cert when no valid cert exists."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleService
+
+        svc = TailscaleService()
+        with (
+            patch.object(svc, "cert_needs_renewal", return_value=True),
+            patch.object(svc, "provision_cert", new_callable=AsyncMock, return_value=True) as mock_prov,
+        ):
+            result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
+
+        assert result is True
+        mock_prov.assert_called_once()
+
+
+# =============================================================================
+# VirtualPrinterInstance Tailscale integration tests
+# =============================================================================
+
+
+class TestVirtualPrinterInstanceTailscale:
+    """Tests for Tailscale cert/advertise resolution in VirtualPrinterInstance."""
+
+    @pytest.fixture
+    def instance(self, tmp_path):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        return VirtualPrinterInstance(
+            vp_id=1,
+            name="TestPrinter",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            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_by_default_queries_tailscale(self, instance):
+        """When tailscale_disabled=False (default), 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):
+        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"")),
+        ):
+            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

+ 32 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -449,6 +449,7 @@ class TestVirtualPrinterManager:
             "remote_interface_ip": "",
             "remote_interface_ip": "",
             "target_printer_id": None,
             "target_printer_id": None,
             "auto_dispatch": True,
             "auto_dispatch": True,
+            "tailscale_disabled": False,
             "position": 0,
             "position": 0,
         }
         }
         defaults.update(overrides)
         defaults.update(overrides)
@@ -618,6 +619,37 @@ class TestVirtualPrinterManager:
 
 
             mock_remove.assert_called_once_with(1)
             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."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="immediate",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            tailscale_disabled=False,
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(tailscale_disabled=True)
+        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()
+
+            mock_remove.assert_called_once_with(1)
+
 
 
 class TestFTPSession:
 class TestFTPSession:
     """Tests for FTP session handling."""
     """Tests for FTP session handling."""

+ 65 - 0
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -134,3 +134,68 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
     });
     });
   });
   });
 });
 });
+
+describe('VirtualPrinterCard - tailscale toggle', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+  });
+
+  it('renders tailscale toggle as enabled (green) when tailscale_disabled is false', async () => {
+    const printer = createMockPrinter({ tailscale_disabled: false });
+    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');
+    expect(section).toBeTruthy();
+    const toggleButton = section!.querySelector('button');
+    expect(toggleButton).toBeTruthy();
+    expect(toggleButton!.className).toContain('bg-bambu-green');
+  });
+
+  it('renders tailscale toggle as disabled (not green) when tailscale_disabled is true', async () => {
+    const printer = createMockPrinter({ tailscale_disabled: true });
+    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');
+    expect(section).toBeTruthy();
+    const toggleButton = section!.querySelector('button');
+    expect(toggleButton).toBeTruthy();
+    expect(toggleButton!.className).not.toContain('bg-bambu-green');
+  });
+
+  it('clicking tailscale toggle calls update API with tailscale_disabled: true', async () => {
+    const user = userEvent.setup();
+    const printer = createMockPrinter({ tailscale_disabled: false });
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
+      createMockPrinter({ tailscale_disabled: true })
+    );
+
+    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');
+    expect(section).toBeTruthy();
+    const toggleButton = section!.querySelector('button');
+    expect(toggleButton).toBeTruthy();
+
+    await user.click(toggleButton!);
+
+    await waitFor(() => {
+      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: true });
+    });
+  });
+});

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

@@ -5335,6 +5335,7 @@ 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 {
@@ -5344,6 +5345,7 @@ export interface VirtualPrinterSettings {
   model: string;
   model: string;
   target_printer_id: number | null;  // For proxy mode
   target_printer_id: number | null;  // For proxy mode
   remote_interface_ip: string | null;  // For SSDP proxy across networks
   remote_interface_ip: string | null;  // For SSDP proxy across networks
+  tailscale_disabled: boolean;
   status: VirtualPrinterStatus;
   status: VirtualPrinterStatus;
 }
 }
 
 
@@ -5386,6 +5388,7 @@ export const virtualPrinterApi = {
     model?: string;
     model?: string;
     target_printer_id?: number;
     target_printer_id?: number;
     remote_interface_ip?: string;
     remote_interface_ip?: string;
+    tailscale_disabled?: boolean;
   }) => {
   }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
@@ -5394,6 +5397,7 @@ export const virtualPrinterApi = {
     if (data.model !== undefined) params.set('model', data.model);
     if (data.model !== undefined) params.set('model', data.model);
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
     if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
     if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
     if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
+    if (data.tailscale_disabled !== undefined) params.set('tailscale_disabled', String(data.tailscale_disabled));
 
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',
       method: 'PUT',
@@ -5413,10 +5417,11 @@ export interface VirtualPrinterConfig {
   serial: string;
   serial: string;
   target_printer_id: number | null;
   target_printer_id: number | null;
   auto_dispatch: boolean;
   auto_dispatch: boolean;
+  tailscale_disabled: boolean;
   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 };
+  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus; tailscale_fqdn?: string };
 }
 }
 
 
 export interface VirtualPrinterListResponse {
 export interface VirtualPrinterListResponse {
@@ -5453,6 +5458,7 @@ export const multiVirtualPrinterApi = {
     access_code?: string;
     access_code?: string;
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
+    tailscale_disabled?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;
   }) =>
   }) =>
@@ -5465,8 +5471,20 @@ export const multiVirtualPrinterApi = {
     request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
     request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+
+  getTailscaleStatus: () =>
+    request<TailscaleStatusResponse>('/virtual-printers/tailscale-status'),
 };
 };
 
 
+export interface TailscaleStatusResponse {
+  available: boolean;
+  fqdn: string;
+  hostname: string;
+  tailnet_name: string;
+  tailscale_ips: string[];
+  error: string | null;
+}
+
 // Pending Uploads API
 // Pending Uploads API
 export const pendingUploadsApi = {
 export const pendingUploadsApi = {
   list: () => request<PendingUpload[]>('/pending-uploads/'),
   list: () => request<PendingUpload[]>('/pending-uploads/'),

+ 37 - 1
frontend/src/components/VirtualPrinterCard.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import {
 import {
   Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
   Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
-  ChevronDown, ChevronRight, ArrowRightLeft, Trash2,
+  ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, multiVirtualPrinterApi } from '../api/client';
 import { api, multiVirtualPrinterApi } from '../api/client';
 import type { VirtualPrinterConfig } from '../api/client';
 import type { VirtualPrinterConfig } from '../api/client';
@@ -43,6 +43,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
+  const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? false);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -58,6 +59,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
       setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
       setLocalModel(printer.model || '');
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
+      setLocalTailscaleDisabled(printer.tailscale_disabled ?? false);
     }
     }
   }, [printer, pendingAction]);
   }, [printer, pendingAction]);
 
 
@@ -209,6 +211,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
           {localRemoteInterfaceIp && (
           {localRemoteInterfaceIp && (
             <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
             <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
           )}
           )}
+          {printer.status?.tailscale_fqdn && (
+            <span className="flex items-center gap-1 text-xs text-green-400/70 flex-shrink-0">
+              <ShieldCheck className="w-3 h-3" />
+              <span className="font-mono text-[10px]">{printer.status.tailscale_fqdn}</span>
+            </span>
+          )}
           <div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
           <div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
             <button
             <button
               onClick={handleToggleEnabled}
               onClick={handleToggleEnabled}
@@ -310,6 +318,34 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </div>
             )}
             )}
 
 
+            {/* Tailscale toggle */}
+            <div className="pt-2 border-t border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between">
+                <div>
+                  <div className="text-white text-sm font-medium">{t('virtualPrinter.tailscaleDisabled.title')}</div>
+                  <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.tailscaleDisabled.description')}</div>
+                </div>
+                <button
+                  onClick={() => {
+                    const newVal = !localTailscaleDisabled;
+                    setLocalTailscaleDisabled(newVal);
+                    setPendingAction('tailscaleDisabled');
+                    updateMutation.mutate({ tailscale_disabled: newVal });
+                  }}
+                  disabled={pendingAction === 'tailscaleDisabled'}
+                  className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
+                    !localTailscaleDisabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                  } ${pendingAction === 'tailscaleDisabled' ? 'opacity-50' : ''}`}
+                >
+                  <span
+                    className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                      !localTailscaleDisabled ? 'translate-x-5' : ''
+                    }`}
+                  />
+                </button>
+              </div>
+            </div>
+
             {/* Printer Model - for non-proxy modes */}
             {/* Printer Model - for non-proxy modes */}
             {localMode !== 'proxy' && (
             {localMode !== 'proxy' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">
               <div className="pt-2 border-t border-bambu-dark-tertiary">

+ 38 - 3
frontend/src/components/VirtualPrinterList.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';
+import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, ShieldCheck, ShieldOff } from 'lucide-react';
 import { multiVirtualPrinterApi } from '../api/client';
 import { multiVirtualPrinterApi } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -18,6 +18,12 @@ export function VirtualPrinterList() {
     refetchInterval: 10000,
     refetchInterval: 10000,
   });
   });
 
 
+  const { data: tailscaleData } = useQuery({
+    queryKey: ['tailscale-status'],
+    queryFn: multiVirtualPrinterApi.getTailscaleStatus,
+    refetchInterval: 30000,
+  });
+
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <Card>
       <Card>
@@ -33,7 +39,7 @@ export function VirtualPrinterList() {
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
-      {/* Top row - Setup Required (25%) + How it works (75%) */}
+      {/* Top row - Setup Required (1 col) + Tailscale (1 col) + How it works (2 cols) */}
       <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-stretch">
       <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-stretch">
         <Card className="border-l-4 border-l-yellow-500">
         <Card className="border-l-4 border-l-yellow-500">
           <CardContent className="py-3 px-4">
           <CardContent className="py-3 px-4">
@@ -56,7 +62,36 @@ export function VirtualPrinterList() {
           </CardContent>
           </CardContent>
         </Card>
         </Card>
 
 
-        <Card className="lg:col-span-3">
+        <Card className={tailscaleData?.available ? 'border-l-4 border-l-green-500' : 'border-l-4 border-l-bambu-dark-tertiary'}>
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-2">
+              {tailscaleData?.available
+                ? <ShieldCheck className="w-4 h-4 text-green-400 shrink-0 mt-0.5" />
+                : <ShieldOff className="w-4 h-4 text-bambu-gray shrink-0 mt-0.5" />
+              }
+              <div className="text-xs">
+                <div className="flex items-center gap-1.5">
+                  <span className={`w-1.5 h-1.5 rounded-full ${tailscaleData?.available ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
+                  <p className={`font-medium ${tailscaleData?.available ? 'text-white' : 'text-bambu-gray'}`}>
+                    {tailscaleData?.available
+                      ? t('virtualPrinter.tailscale.connected')
+                      : t('virtualPrinter.tailscale.notAvailable')}
+                  </p>
+                </div>
+                {tailscaleData?.available ? (
+                  <>
+                    <p className="text-bambu-gray mt-1 font-mono break-all">{tailscaleData.fqdn}</p>
+                    <p className="text-green-400/80 mt-1">{t('virtualPrinter.tailscale.trustedCert')}</p>
+                  </>
+                ) : (
+                  <p className="text-bambu-gray mt-1">{t('virtualPrinter.tailscale.notAvailableHint')}</p>
+                )}
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card className="lg:col-span-2">
           <CardContent className="py-3 px-4">
           <CardContent className="py-3 px-4">
             <div className="flex items-start gap-2">
             <div className="flex items-start gap-2">
               <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
               <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />

+ 13 - 0
frontend/src/i18n/locales/de.ts

@@ -3944,11 +3944,24 @@ export default {
       title: 'Automatisch starten',
       title: 'Automatisch starten',
       description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
       description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
     },
     },
+    tailscaleDisabled: {
+      title: 'Tailscale-Integration',
+      description: 'Wenn aktiviert, werden Tailscale-zertifizierte TLS-Zertifikate verwendet. Deaktivieren für selbstsignierte Zertifikate.',
+    },
     setupRequired: {
     setupRequired: {
       title: 'Einrichtung erforderlich',
       title: 'Einrichtung erforderlich',
       description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',
       description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',
       readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',
       readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',
     },
     },
+    tailscale: {
+      connected: 'Tailscale verbunden',
+      notAvailable: 'Tailscale nicht aktiv',
+      trustedCert: "Let's Encrypt-Zertifikat — keine CA-Einrichtung nötig",
+      notAvailableHint: 'Installiere Tailscale für vertrauenswürdige TLS-Zertifikate',
+      disableTitle: 'Tailscale-Integration deaktivieren',
+      enabledHint: 'Tailscale automatisch erkennen und Let\'s Encrypt-Zertifikat verwenden wenn verfügbar',
+      disabledHint: 'Tailscale deaktiviert — selbstsigniertes Zertifikat wird verwendet',
+    },
     howItWorks: {
     howItWorks: {
       title: 'So funktioniert es',
       title: 'So funktioniert es',
       step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',
       step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',

+ 13 - 0
frontend/src/i18n/locales/en.ts

@@ -3952,11 +3952,24 @@ export default {
       title: 'Auto-dispatch',
       title: 'Auto-dispatch',
       description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
       description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
     },
     },
+    tailscaleDisabled: {
+      title: 'Tailscale integration',
+      description: 'When enabled, uses Tailscale for trusted TLS certs. Disable to use self-signed cert only.',
+    },
     setupRequired: {
     setupRequired: {
       title: 'Setup Required',
       title: 'Setup Required',
       description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',
       description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',
       readGuide: 'Read the setup guide before enabling',
       readGuide: 'Read the setup guide before enabling',
     },
     },
+    tailscale: {
+      connected: 'Tailscale connected',
+      notAvailable: 'Tailscale not active',
+      trustedCert: "Let's Encrypt cert — no CA setup needed",
+      notAvailableHint: 'Install Tailscale for trusted TLS certs',
+      disableTitle: 'Disable Tailscale integration',
+      enabledHint: 'Auto-detect Tailscale and use Let\'s Encrypt cert when available',
+      disabledHint: 'Tailscale disabled — using self-signed cert',
+    },
     howItWorks: {
     howItWorks: {
       title: 'How it works',
       title: 'How it works',
       step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',
       step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -3876,6 +3876,15 @@ export default {
       description: 'Nécessite des réglages système (ports, pare-feu).',
       description: 'Nécessite des réglages système (ports, pare-feu).',
       readGuide: 'Lire le guide de configuration',
       readGuide: 'Lire le guide de configuration',
     },
     },
+    tailscale: {
+      connected: 'Tailscale connecté',
+      notAvailable: 'Tailscale inactif',
+      trustedCert: "Certificat Let's Encrypt — aucune configuration CA requise",
+      notAvailableHint: 'Installez Tailscale pour des certificats TLS approuvés',
+      disableTitle: 'Désactiver l\'intégration Tailscale',
+      enabledHint: 'Détecter Tailscale automatiquement et utiliser le certificat Let\'s Encrypt si disponible',
+      disabledHint: 'Tailscale désactivé — certificat auto-signé utilisé',
+    },
     howItWorks: {
     howItWorks: {
       title: 'Fonctionnement',
       title: 'Fonctionnement',
       step1: 'Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d\'autres réseaux, ajoutez-les manuellement par adresse IP et code d\'accès.',
       step1: 'Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d\'autres réseaux, ajoutez-les manuellement par adresse IP et code d\'accès.',

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -3875,6 +3875,15 @@ export default {
       description: 'La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.',
       description: 'La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.',
       readGuide: 'Leggi la guida prima di abilitare',
       readGuide: 'Leggi la guida prima di abilitare',
     },
     },
+    tailscale: {
+      connected: 'Tailscale connesso',
+      notAvailable: 'Tailscale non attivo',
+      trustedCert: "Certificato Let's Encrypt — nessuna configurazione CA richiesta",
+      notAvailableHint: 'Installa Tailscale per certificati TLS attendibili',
+      disableTitle: 'Disabilita integrazione Tailscale',
+      enabledHint: 'Rileva automaticamente Tailscale e usa il certificato Let\'s Encrypt se disponibile',
+      disabledHint: 'Tailscale disabilitato — viene usato il certificato auto-firmato',
+    },
     howItWorks: {
     howItWorks: {
       title: 'Come funziona',
       title: 'Come funziona',
       step1: 'Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.',
       step1: 'Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.',

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -3914,6 +3914,15 @@ export default {
       description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',
       description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',
       readGuide: '有効にする前にセットアップガイドをお読みください',
       readGuide: '有効にする前にセットアップガイドをお読みください',
     },
     },
+    tailscale: {
+      connected: 'Tailscale 接続済み',
+      notAvailable: 'Tailscale 未接続',
+      trustedCert: "Let's Encrypt 証明書 — CA設定不要",
+      notAvailableHint: '信頼できるTLS証明書にはTailscaleをインストール',
+      disableTitle: 'Tailscale統合を無効にする',
+      enabledHint: 'Tailscaleを自動検出し、利用可能な場合はLet\'s Encrypt証明書を使用',
+      disabledHint: 'Tailscale無効 — 自己署名証明書を使用中',
+    },
     howItWorks: {
     howItWorks: {
       title: '仕組み',
       title: '仕組み',
       step1: '同じLAN上では、仮想プリンターはスライサー(Bambu Studio / OrcaSlicer)に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',
       step1: '同じLAN上では、仮想プリンターはスライサー(Bambu Studio / OrcaSlicer)に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3889,6 +3889,15 @@ export default {
       description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',
       description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',
       readGuide: 'Leia o guia de configuração antes de ativar',
       readGuide: 'Leia o guia de configuração antes de ativar',
     },
     },
+    tailscale: {
+      connected: 'Tailscale conectado',
+      notAvailable: 'Tailscale inativo',
+      trustedCert: "Certificado Let's Encrypt — sem configuração de CA necessária",
+      notAvailableHint: 'Instale o Tailscale para certificados TLS confiáveis',
+      disableTitle: 'Desativar integração com Tailscale',
+      enabledHint: 'Detectar Tailscale automaticamente e usar certificado Let\'s Encrypt quando disponível',
+      disabledHint: 'Tailscale desativado — usando certificado autoassinado',
+    },
     howItWorks: {
     howItWorks: {
       title: 'Como funciona',
       title: 'Como funciona',
       step1: 'Complete o guia de configuração para sua plataforma',
       step1: 'Complete o guia de configuração para sua plataforma',

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3941,6 +3941,15 @@ export default {
       description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
       description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
       readGuide: '启用前请阅读设置指南',
       readGuide: '启用前请阅读设置指南',
     },
     },
+    tailscale: {
+      connected: 'Tailscale 已连接',
+      notAvailable: 'Tailscale 未激活',
+      trustedCert: "Let's Encrypt 证书 — 无需配置 CA",
+      notAvailableHint: '安装 Tailscale 以获取受信任的 TLS 证书',
+      disableTitle: '禁用 Tailscale 集成',
+      enabledHint: '自动检测 Tailscale,可用时使用 Let\'s Encrypt 证书',
+      disabledHint: 'Tailscale 已禁用 — 使用自签名证书',
+    },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',

+ 9 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3941,6 +3941,15 @@ export default {
       description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',
       description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',
       readGuide: '啟用前請閱讀設定指南',
       readGuide: '啟用前請閱讀設定指南',
     },
     },
+    tailscale: {
+      connected: 'Tailscale 已連線',
+      notAvailable: 'Tailscale 未啟用',
+      trustedCert: "Let's Encrypt 憑證 — 無需設定 CA",
+      notAvailableHint: '安裝 Tailscale 以取得受信任的 TLS 憑證',
+      disableTitle: '停用 Tailscale 整合',
+      enabledHint: '自動偵測 Tailscale,可用時使用 Let\'s Encrypt 憑證',
+      disabledHint: 'Tailscale 已停用 — 使用自簽憑證',
+    },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',

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


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


+ 40 - 40
static/index.html

@@ -1,40 +1,40 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
-    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
-         sensitive tokens in query parameters are not leaked to third-party servers. -->
-    <meta name="referrer" content="strict-origin-when-cross-origin" />
-    <title>Bambuddy</title>
-
-    <!-- PWA Meta Tags -->
-    <meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
-    <meta name="theme-color" content="#00ae42" />
-    <meta name="mobile-web-app-capable" content="yes" />
-    <meta name="apple-mobile-web-app-capable" content="yes" />
-    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
-    <meta name="apple-mobile-web-app-title" content="Bambuddy" />
-
-    <!-- Manifest -->
-    <link rel="manifest" href="/manifest.json" />
-
-    <!-- Favicons -->
-    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
-    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
-    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-
-    <!-- Splash screens for iOS -->
-    <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D6O7X0cZ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DSTnupH9.css">
-  </head>
-  <body>
-    <div id="root"></div>
-
-    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
-         Kept as an external file so the CSP `script-src 'self'` covers it
-         without needing 'unsafe-inline' or per-build hashes. -->
-    <script src="/sw-register.js"></script>
-  </body>
-</html>
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
+         sensitive tokens in query parameters are not leaked to third-party servers. -->
+    <meta name="referrer" content="strict-origin-when-cross-origin" />
+    <title>Bambuddy</title>
+
+    <!-- PWA Meta Tags -->
+    <meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
+    <meta name="theme-color" content="#00ae42" />
+    <meta name="mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Bambuddy" />
+
+    <!-- Manifest -->
+    <link rel="manifest" href="/manifest.json" />
+
+    <!-- Favicons -->
+    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
+
+    <!-- Splash screens for iOS -->
+    <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
+    <script type="module" crossorigin src="/assets/index-D6O7X0cZ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DSTnupH9.css">
+  </head>
+  <body>
+    <div id="root"></div>
+
+    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
+         Kept as an external file so the CSP `script-src 'self'` covers it
+         without needing 'unsafe-inline' or per-build hashes. -->
+    <script src="/sw-register.js"></script>
+  </body>
+</html>

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