mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
* feat(virtual-printer): add Tailscale certificate provisioning
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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アドレスとアクセスコードで手動で追加してください。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 地址和访问码手动添加。',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user