Files
bambuddy/backend/app/api/routes/virtual_printers.py
T
maziggy 64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
2026-05-03 14:58:29 +02:00

464 lines
18 KiB
Python

import logging
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
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
mode: str = "immediate"
model: str | None = None
access_code: str | None = None
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
bind_ip: str | None = None
remote_interface_ip: str | None = None
class VirtualPrinterUpdate(BaseModel):
name: str | None = None
enabled: bool | None = None
mode: str | None = None
model: str | None = None
access_code: str | None = None
target_printer_id: int | None = None
auto_dispatch: bool | None = None
queue_force_color_match: 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:
"""Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
"""
if not printer_model:
return None
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
# Already a valid SSDP model code
if printer_model in VIRTUAL_PRINTER_MODELS:
return printer_model
# Map display name to SSDP code
return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
def _vp_to_dict(vp, status: dict | None = None) -> dict:
"""Convert VirtualPrinter model to response dict."""
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
serial = _get_serial_for_model(model_code, vp.serial_suffix)
return {
"id": vp.id,
"name": vp.name,
"enabled": vp.enabled,
"mode": vp.mode,
"model": model_code,
"model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
"access_code_set": bool(vp.access_code),
"serial": serial,
"target_printer_id": vp.target_printer_id,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
"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},
}
@router.get("")
async def list_virtual_printers(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""List all virtual printers with status."""
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
vps = result.scalars().all()
printers = []
for vp in vps:
instance = virtual_printer_manager.get_instance(vp.id)
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
printers.append(_vp_to_dict(vp, status))
return {
"printers": printers,
"models": VIRTUAL_PRINTER_MODELS,
}
@router.post("")
async def create_virtual_printer(
body: VirtualPrinterCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Create a new virtual printer."""
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
# Validate mode
if body.mode not in ("immediate", "review", "print_queue", "proxy"):
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
# Validate model
if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
return JSONResponse(
status_code=400,
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
)
# Validate access code length
if body.access_code and len(body.access_code) != 8:
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
# Validation when enabling
if body.enabled:
if not body.bind_ip:
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
if body.mode == "proxy":
if not body.target_printer_id:
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
else:
if not body.access_code:
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
# Validate proxy target printer exists
target_printer = None
if body.target_printer_id:
from backend.app.models.printer import Printer
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
target_printer = result.scalar_one_or_none()
if not target_printer:
return JSONResponse(
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
)
# Validate bind_ip uniqueness (against all enabled VPs)
if body.bind_ip:
result = await db.execute(
select(VirtualPrinter).where(
VirtualPrinter.bind_ip == body.bind_ip,
VirtualPrinter.enabled == True, # noqa: E712
)
)
if result.scalar_one_or_none():
return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
# Generate next serial suffix
result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
last_suffix = result.scalar()
if last_suffix:
try:
next_num = int(last_suffix) + 1
new_suffix = str(next_num).zfill(9)
except ValueError:
new_suffix = "391800002"
else:
new_suffix = "391800001"
# Get next position
result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
last_pos = result.scalar()
next_pos = (last_pos or 0) + 1
vp = VirtualPrinter(
name=body.name,
enabled=body.enabled,
mode=body.mode,
model=body.model
or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
or DEFAULT_VIRTUAL_PRINTER_MODEL,
access_code=body.access_code,
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
bind_ip=body.bind_ip,
remote_interface_ip=body.remote_interface_ip,
serial_suffix=new_suffix,
position=next_pos,
)
db.add(vp)
await db.commit()
await db.refresh(vp)
logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
# Sync services if enabled
if body.enabled:
try:
await virtual_printer_manager.sync_from_db()
except Exception as e:
logger.error("Failed to start virtual printer after create: %s", e)
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,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Get a single virtual printer with status."""
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import virtual_printer_manager
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
vp = result.scalar_one_or_none()
if not vp:
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
instance = virtual_printer_manager.get_instance(vp.id)
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
return _vp_to_dict(vp, status)
@router.put("/{vp_id}")
async def update_virtual_printer(
vp_id: int,
body: VirtualPrinterUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update a virtual printer."""
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
vp = result.scalar_one_or_none()
if not vp:
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
logger.debug(
"Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
vp_id,
body.model_dump(exclude_unset=True),
vp.mode,
vp.enabled,
bool(vp.access_code),
vp.bind_ip,
vp.target_printer_id,
)
# Apply updates
if body.name is not None:
vp.name = body.name
if body.mode is not None:
if body.mode not in ("immediate", "review", "print_queue", "proxy"):
return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
vp.mode = body.mode
if body.model is not None:
if body.model not in VIRTUAL_PRINTER_MODELS:
return JSONResponse(
status_code=400,
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
)
vp.model = body.model
if body.access_code is not None:
if body.access_code and len(body.access_code) != 8:
return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
vp.access_code = body.access_code
if body.target_printer_id is not None:
from backend.app.models.printer import Printer
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
target_printer = result.scalar_one_or_none()
if not target_printer:
return JSONResponse(
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
)
vp.target_printer_id = body.target_printer_id
# Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
if body.model is None and vp.mode == "proxy" and target_printer.model:
vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
if body.auto_dispatch is not None:
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
vp.queue_force_color_match = body.queue_force_color_match
if body.bind_ip is not None:
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:
from backend.app.models.printer import Printer as PrinterModel
result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
existing_target = result.scalar_one_or_none()
if existing_target and existing_target.model:
vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
# Determine final enabled state
explicitly_enabling = body.enabled is True
new_enabled = body.enabled if body.enabled is not None else vp.enabled
effective_mode = vp.mode
if explicitly_enabling:
# User is explicitly toggling on — enforce all requirements
if not vp.bind_ip:
logger.warning("Update VP %d rejected: no bind_ip", vp_id)
return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
# Validate bind_ip uniqueness (against all enabled VPs)
existing = await db.execute(
select(VirtualPrinter).where(
VirtualPrinter.bind_ip == vp.bind_ip,
VirtualPrinter.id != vp_id,
VirtualPrinter.enabled == True, # noqa: E712
)
)
conflict = existing.scalar_one_or_none()
if conflict:
logger.warning(
"Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
vp_id,
vp.bind_ip,
conflict.id,
conflict.enabled,
conflict.mode,
)
return JSONResponse(
status_code=400,
content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
)
if effective_mode == "proxy":
if not vp.target_printer_id:
logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
else:
if not vp.access_code:
logger.warning(
"Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
)
return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
elif new_enabled and body.enabled is None:
# VP is already enabled and user is changing other fields —
# auto-disable if new state doesn't meet requirements
if not vp.bind_ip:
new_enabled = False
elif effective_mode == "proxy":
if not vp.target_printer_id:
new_enabled = False
else:
if not vp.access_code:
new_enabled = False
vp.enabled = new_enabled
await db.commit()
await db.refresh(vp)
logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
# Sync services
try:
await virtual_printer_manager.sync_from_db()
except Exception as e:
logger.error("Failed to sync virtual printers after update: %s", e)
instance = virtual_printer_manager.get_instance(vp.id)
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
return _vp_to_dict(vp, status)
@router.delete("/{vp_id}")
async def delete_virtual_printer(
vp_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Delete a virtual printer."""
from sqlalchemy import delete as sql_delete
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.services.virtual_printer import virtual_printer_manager
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
vp = result.scalar_one_or_none()
if not vp:
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
vp_name = vp.name
# Stop instance if running
await virtual_printer_manager.remove_instance(vp_id)
# Delete from DB
await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
await db.commit()
logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
# Resync remaining services
try:
await virtual_printer_manager.sync_from_db()
except Exception as e:
logger.error("Failed to sync virtual printers after delete: %s", e)
return {"detail": "Deleted", "id": vp_id}