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

* feat(virtual-printer): add Tailscale certificate provisioning
This commit is contained in:
lietschaend
2026-04-24 09:59:09 +02:00
committed by GitHub
parent 1b284a9e39
commit 91a3d391ff
25 changed files with 10445 additions and 51 deletions
+18
View File
@@ -844,6 +844,7 @@ async def get_virtual_printer_settings(
model = await get_setting(db, "virtual_printer_model")
target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
return {
"enabled": enabled == "true" if enabled else False,
@@ -852,6 +853,7 @@ async def get_virtual_printer_settings(
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
"target_printer_id": int(target_printer_id) if target_printer_id else None,
"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(),
}
@@ -864,6 +866,7 @@ async def update_virtual_printer_settings(
model: str = None,
target_printer_id: int = None,
remote_interface_ip: str = None,
tailscale_disabled: bool = None,
db: AsyncSession = Depends(get_db),
_: 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 = 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_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
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_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_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
# Validate mode
# "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))
if remote_interface_ip is not None:
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()
db.expire_all()
@@ -11,11 +11,24 @@ from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
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__)
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):
name: str = "Bambuddy"
enabled: bool = False
@@ -38,6 +51,7 @@ class VirtualPrinterUpdate(BaseModel):
auto_dispatch: bool | None = None
bind_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:
@@ -78,6 +92,7 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
"auto_dispatch": vp.auto_dispatch,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
"tailscale_disabled": vp.tailscale_disabled,
"position": vp.position,
"status": status or {"running": False, "pending_files": 0},
}
@@ -215,6 +230,26 @@ async def create_virtual_printer(
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}")
async def get_virtual_printer(
vp_id: int,
@@ -300,6 +335,8 @@ async def update_virtual_printer(
vp.bind_ip = body.bind_ip
if body.remote_interface_ip is not None:
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
if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
+3
View File
@@ -480,6 +480,9 @@ async def run_migrations(conn):
# 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)")
# 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
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
+3
View File
@@ -25,6 +25,9 @@ class VirtualPrinter(Base):
) # 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
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
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
@@ -132,7 +132,10 @@ class CertificateService:
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))
logger.info("Saved new CA certificate")
@@ -311,7 +314,10 @@ class CertificateService:
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)
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)
return self.cert_path, self.key_path
# -- Tailscale cert support --
@property
def ts_cert_path(self) -> Path:
"""Path for Tailscale-provisioned cert (separate from self-signed)."""
return self.cert_dir / "virtual_printer_ts.crt"
@property
def ts_key_path(self) -> Path:
"""Path for Tailscale-provisioned private key."""
return self.cert_dir / "virtual_printer_ts.key"
async def use_tailscale_cert(
self,
fqdn: str,
tailscale_svc: object,
) -> tuple[Path, Path] | None:
"""Attempt to provision a Tailscale LE cert for fqdn.
Delegates to tailscale_svc.ensure_cert(). Returns (cert_path, key_path)
on success, None if Tailscale provisioning fails.
"""
ok = await tailscale_svc.ensure_cert(fqdn, self.ts_cert_path, self.ts_key_path)
if ok:
return self.ts_cert_path, self.ts_key_path
return None
def delete_printer_certificate(self) -> None:
"""Delete only the printer certificate (preserves CA)."""
for path in [self.cert_path, self.key_path]:
+148 -4
View File
@@ -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.mqtt_server import SimpleMQTTServer
from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
from backend.app.services.virtual_printer.tailscale import tailscale_service
from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
logger = logging.getLogger(__name__)
@@ -112,6 +113,7 @@ class VirtualPrinterInstance:
auto_dispatch: bool = True,
bind_ip: str = "",
remote_interface_ip: str = "",
tailscale_disabled: bool = False,
base_dir: Path,
session_factory: Callable | None = None,
):
@@ -127,6 +129,7 @@ class VirtualPrinterInstance:
self.auto_dispatch = auto_dispatch
self.bind_ip = bind_ip
self.remote_interface_ip = remote_interface_ip
self.tailscale_disabled = tailscale_disabled
self._session_factory = session_factory
# Directories
@@ -146,6 +149,9 @@ class VirtualPrinterInstance:
shared_ca_dir=shared_ca_dir,
)
# Tailscale FQDN used for this instance (set at start_server/start_proxy time)
self.tailscale_fqdn: str | None = None
# Pending files for MQTT correlation
self._pending_files: dict[str, Path] = {}
@@ -157,6 +163,8 @@ class VirtualPrinterInstance:
self._ssdp: VirtualPrinterSSDPServer | None = None
self._ssdp_proxy: SSDPProxy | None = None
self._tasks: list[asyncio.Task] = []
self._cert_renewal_task: asyncio.Task | None = None
self._cert_restart_task: asyncio.Task | None = None
@property
def serial(self) -> str:
@@ -366,11 +374,129 @@ class VirtualPrinterInstance:
# -- Service lifecycle --
async def _cancel_renewal_task(self) -> None:
"""Cancel the cert renewal task and await its completion."""
if self._cert_renewal_task:
self._cert_renewal_task.cancel()
try:
await self._cert_renewal_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning("[VP %s] Unexpected error in cert renewal task: %s", self.name, e)
self._cert_renewal_task = None
async def _cancel_restart_task(self) -> None:
"""Cancel the cert restart task and await its completion."""
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:
"""Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip."""
logger.info("[VP %s] Starting server-mode services on %s", self.name, self.bind_ip)
cert_path, key_path = 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
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(
name=self.name,
serial=self.serial,
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,
)
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)
async def stop_server(self) -> None:
"""Stop server-mode services."""
await self._cancel_renewal_task()
await self._cancel_restart_task()
if self._ftp:
await self._ftp.stop()
self._ftp = None
@@ -469,7 +602,7 @@ class VirtualPrinterInstance:
"""Start proxy mode services for this instance."""
logger.info("[VP %s] Starting proxy mode to %s", self.name, self.target_printer_ip)
cert_path, key_path = self.generate_certificates()
cert_path, key_path, _ = await self._resolve_cert_and_advertise()
self._proxy = SlicerProxyManager(
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:
"""Start single-interface SSDP server as fallback for proxy mode."""
self._ssdp = VirtualPrinterSSDPServer(
@@ -542,6 +679,8 @@ class VirtualPrinterInstance:
async def stop_proxy(self) -> None:
"""Stop proxy mode services for this instance."""
await self._cancel_renewal_task()
await self._cancel_restart_task()
if self._proxy:
await self._proxy.stop()
self._proxy = None
@@ -570,6 +709,8 @@ class VirtualPrinterInstance:
"running": self.is_running,
"pending_files": len(self._pending_files),
}
if self.tailscale_fqdn:
status["tailscale_fqdn"] = self.tailscale_fqdn
if self.is_proxy and self._proxy:
status["proxy"] = self._proxy.get_status()
return status
@@ -661,6 +802,7 @@ class VirtualPrinterManager:
or instance.remote_interface_ip != (vp.remote_interface_ip or "")
or instance.target_printer_id != vp.target_printer_id
or instance.auto_dispatch != vp.auto_dispatch
or instance.tailscale_disabled != vp.tailscale_disabled
)
if changed:
@@ -695,6 +837,7 @@ class VirtualPrinterManager:
auto_dispatch=vp.auto_dispatch,
bind_ip=vp.bind_ip or "",
remote_interface_ip=vp.remote_interface_ip or "",
tailscale_disabled=vp.tailscale_disabled,
base_dir=self._base_dir,
session_factory=self._session_factory,
)
@@ -713,6 +856,7 @@ class VirtualPrinterManager:
auto_dispatch=vp.auto_dispatch,
bind_ip=vp.bind_ip or "",
remote_interface_ip=vp.remote_interface_ip or "",
tailscale_disabled=vp.tailscale_disabled,
base_dir=self._base_dir,
session_factory=self._session_factory,
)
@@ -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()
@@ -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"
@@ -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
@@ -449,6 +449,7 @@ class TestVirtualPrinterManager:
"remote_interface_ip": "",
"target_printer_id": None,
"auto_dispatch": True,
"tailscale_disabled": False,
"position": 0,
}
defaults.update(overrides)
@@ -618,6 +619,37 @@ class TestVirtualPrinterManager:
mock_remove.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_sync_from_db_restarts_on_tailscale_disabled_change(self, manager, tmp_path):
"""VP restarts when tailscale_disabled flips from False to True."""
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:
"""Tests for FTP session handling."""
@@ -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
View File
@@ -5335,6 +5335,7 @@ export interface VirtualPrinterStatus {
pending_files: number;
target_printer_ip?: string; // For proxy mode
proxy?: VirtualPrinterProxyStatus; // For proxy mode
tailscale_fqdn?: string; // Set when Tailscale cert is active
}
export interface VirtualPrinterSettings {
@@ -5344,6 +5345,7 @@ export interface VirtualPrinterSettings {
model: string;
target_printer_id: number | null; // For proxy mode
remote_interface_ip: string | null; // For SSDP proxy across networks
tailscale_disabled: boolean;
status: VirtualPrinterStatus;
}
@@ -5386,6 +5388,7 @@ export const virtualPrinterApi = {
model?: string;
target_printer_id?: number;
remote_interface_ip?: string;
tailscale_disabled?: boolean;
}) => {
const params = new URLSearchParams();
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.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.tailscale_disabled !== undefined) params.set('tailscale_disabled', String(data.tailscale_disabled));
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
method: 'PUT',
@@ -5413,10 +5417,11 @@ export interface VirtualPrinterConfig {
serial: string;
target_printer_id: number | null;
auto_dispatch: boolean;
tailscale_disabled: boolean;
bind_ip: string | null;
remote_interface_ip: string | null;
position: number;
status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus; tailscale_fqdn?: string };
}
export interface VirtualPrinterListResponse {
@@ -5453,6 +5458,7 @@ export const multiVirtualPrinterApi = {
access_code?: string;
target_printer_id?: number;
auto_dispatch?: boolean;
tailscale_disabled?: boolean;
bind_ip?: string;
remote_interface_ip?: string;
}) =>
@@ -5465,8 +5471,20 @@ export const multiVirtualPrinterApi = {
request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
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
export const pendingUploadsApi = {
list: () => request<PendingUpload[]>('/pending-uploads/'),
+37 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import {
Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
ChevronDown, ChevronRight, ArrowRightLeft, Trash2,
ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck,
} from 'lucide-react';
import { api, multiVirtualPrinterApi } 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 [localModel, setLocalModel] = useState(printer.model || '');
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? false);
const [showAccessCode, setShowAccessCode] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -58,6 +59,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
setLocalModel(printer.model || '');
setLocalAutoDispatch(printer.auto_dispatch ?? true);
setLocalTailscaleDisabled(printer.tailscale_disabled ?? false);
}
}, [printer, pendingAction]);
@@ -209,6 +211,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
{localRemoteInterfaceIp && (
<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()}>
<button
onClick={handleToggleEnabled}
@@ -310,6 +318,34 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
</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 */}
{localMode !== 'proxy' && (
<div className="pt-2 border-t border-bambu-dark-tertiary">
+38 -3
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -18,6 +18,12 @@ export function VirtualPrinterList() {
refetchInterval: 10000,
});
const { data: tailscaleData } = useQuery({
queryKey: ['tailscale-status'],
queryFn: multiVirtualPrinterApi.getTailscaleStatus,
refetchInterval: 30000,
});
if (isLoading) {
return (
<Card>
@@ -33,7 +39,7 @@ export function VirtualPrinterList() {
return (
<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">
<Card className="border-l-4 border-l-yellow-500">
<CardContent className="py-3 px-4">
@@ -56,7 +62,36 @@ export function VirtualPrinterList() {
</CardContent>
</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">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+13
View File
@@ -3944,11 +3944,24 @@ export default {
title: 'Automatisch starten',
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: {
title: 'Einrichtung erforderlich',
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',
},
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: {
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.',
+13
View File
@@ -3952,11 +3952,24 @@ export default {
title: 'Auto-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: {
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.',
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: {
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.',
+9
View File
@@ -3876,6 +3876,15 @@ export default {
description: 'Nécessite des réglages système (ports, pare-feu).',
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: {
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.',
+9
View File
@@ -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.',
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: {
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.',
+9
View File
@@ -3914,6 +3914,15 @@ export default {
description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',
readGuide: '有効にする前にセットアップガイドをお読みください',
},
tailscale: {
connected: 'Tailscale 接続済み',
notAvailable: 'Tailscale 未接続',
trustedCert: "Let's Encrypt 証明書 — CA設定不要",
notAvailableHint: '信頼できるTLS証明書にはTailscaleをインストール',
disableTitle: 'Tailscale統合を無効にする',
enabledHint: 'Tailscaleを自動検出し、利用可能な場合はLet\'s Encrypt証明書を使用',
disabledHint: 'Tailscale無効 — 自己署名証明書を使用中',
},
howItWorks: {
title: '仕組み',
step1: '同じLAN上では、仮想プリンターはスライサー(Bambu Studio / OrcaSlicer)に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',
+9
View File
@@ -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.',
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: {
title: 'Como funciona',
step1: 'Complete o guia de configuração para sua plataforma',
+9
View File
@@ -3941,6 +3941,15 @@ export default {
description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
readGuide: '启用前请阅读设置指南',
},
tailscale: {
connected: 'Tailscale 已连接',
notAvailable: 'Tailscale 未激活',
trustedCert: "Let's Encrypt 证书 — 无需配置 CA",
notAvailableHint: '安装 Tailscale 以获取受信任的 TLS 证书',
disableTitle: '禁用 Tailscale 集成',
enabledHint: '自动检测 Tailscale,可用时使用 Let\'s Encrypt 证书',
disabledHint: 'Tailscale 已禁用 — 使用自签名证书',
},
howItWorks: {
title: '工作原理',
step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',
+9
View File
@@ -3941,6 +3941,15 @@ export default {
description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',
readGuide: '啟用前請閱讀設定指南',
},
tailscale: {
connected: 'Tailscale 已連線',
notAvailable: 'Tailscale 未啟用',
trustedCert: "Let's Encrypt 憑證 — 無需設定 CA",
notAvailableHint: '安裝 Tailscale 以取得受信任的 TLS 憑證',
disableTitle: '停用 Tailscale 整合',
enabledHint: '自動偵測 Tailscale,可用時使用 Let\'s Encrypt 憑證',
disabledHint: 'Tailscale 已停用 — 使用自簽憑證',
},
howItWorks: {
title: '工作原理',
step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40 -40
View File
@@ -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>