virtual_printers.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import logging
  2. from fastapi import APIRouter, Depends
  3. from fastapi.responses import JSONResponse
  4. from pydantic import BaseModel
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.models.user import User
  11. from backend.app.schemas.virtual_printer import VPDiagnosticResult
  12. # Imported at module scope so tests can patch
  13. # backend.app.api.routes.virtual_printers.tailscale_service.
  14. from backend.app.services.virtual_printer.tailscale import tailscale_service
  15. logger = logging.getLogger(__name__)
  16. router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
  17. class TailscaleStatusResponse(BaseModel):
  18. available: bool
  19. fqdn: str
  20. hostname: str
  21. tailnet_name: str
  22. tailscale_ips: list[str]
  23. error: str | None
  24. class VirtualPrinterCreate(BaseModel):
  25. name: str = "Bambuddy"
  26. enabled: bool = False
  27. mode: str = "archive"
  28. model: str | None = None
  29. access_code: str | None = None
  30. target_printer_id: int | None = None
  31. auto_dispatch: bool = True
  32. queue_force_color_match: bool = False
  33. bind_ip: str | None = None
  34. remote_interface_ip: str | None = None
  35. class VirtualPrinterUpdate(BaseModel):
  36. name: str | None = None
  37. enabled: bool | None = None
  38. mode: str | None = None
  39. model: str | None = None
  40. access_code: str | None = None
  41. target_printer_id: int | None = None
  42. auto_dispatch: bool | None = None
  43. queue_force_color_match: bool | None = None
  44. bind_ip: str | None = None
  45. remote_interface_ip: str | None = None
  46. tailscale_disabled: bool | None = None
  47. def _resolve_printer_model(printer_model: str | None) -> str | None:
  48. """Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
  49. Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
  50. """
  51. if not printer_model:
  52. return None
  53. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  54. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
  55. # Already a valid SSDP model code
  56. if printer_model in VIRTUAL_PRINTER_MODELS:
  57. return printer_model
  58. # Map display name to SSDP code
  59. return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
  60. def _vp_to_dict(vp, status: dict | None = None) -> dict:
  61. """Convert VirtualPrinter model to response dict."""
  62. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  63. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
  64. model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
  65. serial = _get_serial_for_model(model_code, vp.serial_suffix)
  66. return {
  67. "id": vp.id,
  68. "name": vp.name,
  69. "enabled": vp.enabled,
  70. "mode": vp.mode,
  71. "model": model_code,
  72. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  73. "access_code_set": bool(vp.access_code),
  74. "serial": serial,
  75. "target_printer_id": vp.target_printer_id,
  76. "auto_dispatch": vp.auto_dispatch,
  77. "queue_force_color_match": vp.queue_force_color_match,
  78. "bind_ip": vp.bind_ip,
  79. "remote_interface_ip": vp.remote_interface_ip,
  80. "tailscale_disabled": vp.tailscale_disabled,
  81. "position": vp.position,
  82. "status": status or {"running": False, "pending_files": 0},
  83. }
  84. @router.get("")
  85. async def list_virtual_printers(
  86. db: AsyncSession = Depends(get_db),
  87. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  88. ):
  89. """List all virtual printers with status."""
  90. from backend.app.models.virtual_printer import VirtualPrinter
  91. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  92. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
  93. vps = result.scalars().all()
  94. printers = []
  95. for vp in vps:
  96. instance = virtual_printer_manager.get_instance(vp.id)
  97. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  98. printers.append(_vp_to_dict(vp, status))
  99. return {
  100. "printers": printers,
  101. "models": VIRTUAL_PRINTER_MODELS,
  102. }
  103. @router.post("")
  104. async def create_virtual_printer(
  105. body: VirtualPrinterCreate,
  106. db: AsyncSession = Depends(get_db),
  107. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  108. ):
  109. """Create a new virtual printer."""
  110. from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
  111. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  112. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
  113. # Accept both canonical and legacy wire values so older clients (forks /
  114. # mobile shortcuts / scripted setups) still work; normalize before write.
  115. body.mode = normalize_vp_mode(body.mode) or body.mode
  116. if body.mode not in VP_MODE_VALUES:
  117. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  118. # Validate model
  119. if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
  120. return JSONResponse(
  121. status_code=400,
  122. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  123. )
  124. # Validate access code length
  125. if body.access_code and len(body.access_code) != 8:
  126. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  127. # Validation when enabling
  128. if body.enabled:
  129. if not body.bind_ip:
  130. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  131. if body.mode == "proxy":
  132. if not body.target_printer_id:
  133. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  134. else:
  135. if not body.access_code:
  136. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  137. # Validate proxy target printer exists
  138. target_printer = None
  139. if body.target_printer_id:
  140. from backend.app.models.printer import Printer
  141. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  142. target_printer = result.scalar_one_or_none()
  143. if not target_printer:
  144. return JSONResponse(
  145. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  146. )
  147. # Validate bind_ip uniqueness (against all enabled VPs)
  148. if body.bind_ip:
  149. result = await db.execute(
  150. select(VirtualPrinter).where(
  151. VirtualPrinter.bind_ip == body.bind_ip,
  152. VirtualPrinter.enabled == True, # noqa: E712
  153. )
  154. )
  155. if result.scalar_one_or_none():
  156. return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
  157. # Generate next serial suffix
  158. result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
  159. last_suffix = result.scalar()
  160. if last_suffix:
  161. try:
  162. next_num = int(last_suffix) + 1
  163. new_suffix = str(next_num).zfill(9)
  164. except ValueError:
  165. new_suffix = "391800002"
  166. else:
  167. new_suffix = "391800001"
  168. # Get next position
  169. result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
  170. last_pos = result.scalar()
  171. next_pos = (last_pos or 0) + 1
  172. vp = VirtualPrinter(
  173. name=body.name,
  174. enabled=body.enabled,
  175. mode=body.mode,
  176. model=body.model
  177. or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
  178. or DEFAULT_VIRTUAL_PRINTER_MODEL,
  179. access_code=body.access_code,
  180. target_printer_id=body.target_printer_id,
  181. auto_dispatch=body.auto_dispatch,
  182. queue_force_color_match=body.queue_force_color_match,
  183. bind_ip=body.bind_ip,
  184. remote_interface_ip=body.remote_interface_ip,
  185. serial_suffix=new_suffix,
  186. position=next_pos,
  187. )
  188. db.add(vp)
  189. await db.commit()
  190. await db.refresh(vp)
  191. logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
  192. # Sync services if enabled
  193. if body.enabled:
  194. try:
  195. await virtual_printer_manager.sync_from_db()
  196. except Exception as e:
  197. logger.error("Failed to start virtual printer after create: %s", e)
  198. return _vp_to_dict(vp)
  199. @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
  200. async def get_tailscale_status(
  201. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  202. ) -> TailscaleStatusResponse:
  203. """Return current Tailscale availability and machine identity.
  204. Used by the frontend to indicate whether virtual printer TLS is backed
  205. by a trusted Let's Encrypt certificate or a self-signed CA.
  206. """
  207. status = await tailscale_service.get_status()
  208. return TailscaleStatusResponse(
  209. available=status.available,
  210. fqdn=status.fqdn,
  211. hostname=status.hostname,
  212. tailnet_name=status.tailnet_name,
  213. tailscale_ips=status.tailscale_ips,
  214. error=status.error,
  215. )
  216. @router.get("/ca-certificate")
  217. async def get_ca_certificate(
  218. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  219. ):
  220. """Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
  221. One CA is shared by every virtual printer — the user imports it into their
  222. slicer's trust store once. Only the public certificate is returned; the CA
  223. private key never leaves the backend.
  224. """
  225. from backend.app.services.virtual_printer import virtual_printer_manager
  226. try:
  227. return virtual_printer_manager.get_ca_certificate_info()
  228. except Exception as e:
  229. logger.error("Failed to obtain virtual printer CA certificate: %s", e)
  230. return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
  231. @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
  232. async def diagnose_virtual_printer(
  233. vp_id: int,
  234. db: AsyncSession = Depends(get_db),
  235. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  236. ):
  237. """Run setup diagnostics for a virtual printer.
  238. Probes the VP's own bind IP and services so the user can self-diagnose the
  239. common "my virtual printer doesn't show up in the slicer" failures.
  240. """
  241. from backend.app.models.virtual_printer import VirtualPrinter
  242. from backend.app.services.virtual_printer import virtual_printer_manager
  243. from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
  244. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  245. vp = result.scalar_one_or_none()
  246. if not vp:
  247. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  248. instance = virtual_printer_manager.get_instance(vp.id)
  249. return await run_vp_diagnostic(vp, instance)
  250. @router.get("/{vp_id}")
  251. async def get_virtual_printer(
  252. vp_id: int,
  253. db: AsyncSession = Depends(get_db),
  254. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  255. ):
  256. """Get a single virtual printer with status."""
  257. from backend.app.models.virtual_printer import VirtualPrinter
  258. from backend.app.services.virtual_printer import virtual_printer_manager
  259. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  260. vp = result.scalar_one_or_none()
  261. if not vp:
  262. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  263. instance = virtual_printer_manager.get_instance(vp.id)
  264. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  265. return _vp_to_dict(vp, status)
  266. @router.put("/{vp_id}")
  267. async def update_virtual_printer(
  268. vp_id: int,
  269. body: VirtualPrinterUpdate,
  270. db: AsyncSession = Depends(get_db),
  271. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  272. ):
  273. """Update a virtual printer."""
  274. from backend.app.models.virtual_printer import VirtualPrinter
  275. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  276. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  277. vp = result.scalar_one_or_none()
  278. if not vp:
  279. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  280. # Redact the access code before logging — model_dump otherwise includes
  281. # the plaintext value at DEBUG, violating the project no-secrets-in-logs
  282. # rule. Replace with a marker that still signals "the user changed it"
  283. # vs "the user didn't touch this field".
  284. _safe_body = body.model_dump(exclude_unset=True)
  285. if "access_code" in _safe_body:
  286. _safe_body["access_code"] = "***"
  287. logger.debug(
  288. "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
  289. vp_id,
  290. _safe_body,
  291. vp.mode,
  292. vp.enabled,
  293. bool(vp.access_code),
  294. vp.bind_ip,
  295. vp.target_printer_id,
  296. )
  297. # Apply updates
  298. if body.name is not None:
  299. vp.name = body.name
  300. if body.mode is not None:
  301. from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
  302. canonical_mode = normalize_vp_mode(body.mode) or body.mode
  303. if canonical_mode not in VP_MODE_VALUES:
  304. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  305. vp.mode = canonical_mode
  306. if body.model is not None:
  307. if body.model not in VIRTUAL_PRINTER_MODELS:
  308. return JSONResponse(
  309. status_code=400,
  310. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  311. )
  312. vp.model = body.model
  313. if body.access_code is not None:
  314. if body.access_code and len(body.access_code) != 8:
  315. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  316. vp.access_code = body.access_code
  317. if body.target_printer_id is not None:
  318. from backend.app.models.printer import Printer
  319. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  320. target_printer = result.scalar_one_or_none()
  321. if not target_printer:
  322. return JSONResponse(
  323. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  324. )
  325. vp.target_printer_id = body.target_printer_id
  326. # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
  327. if body.model is None and vp.mode == "proxy" and target_printer.model:
  328. vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
  329. if body.auto_dispatch is not None:
  330. vp.auto_dispatch = body.auto_dispatch
  331. if body.queue_force_color_match is not None:
  332. vp.queue_force_color_match = body.queue_force_color_match
  333. if body.bind_ip is not None:
  334. vp.bind_ip = body.bind_ip
  335. if body.remote_interface_ip is not None:
  336. vp.remote_interface_ip = body.remote_interface_ip
  337. if body.tailscale_disabled is not None:
  338. vp.tailscale_disabled = body.tailscale_disabled
  339. # Auto-inherit model when switching to proxy mode with existing target printer
  340. if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
  341. from backend.app.models.printer import Printer as PrinterModel
  342. result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
  343. existing_target = result.scalar_one_or_none()
  344. if existing_target and existing_target.model:
  345. vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
  346. # Determine final enabled state
  347. explicitly_enabling = body.enabled is True
  348. new_enabled = body.enabled if body.enabled is not None else vp.enabled
  349. effective_mode = vp.mode
  350. if explicitly_enabling:
  351. # User is explicitly toggling on — enforce all requirements
  352. if not vp.bind_ip:
  353. logger.warning("Update VP %d rejected: no bind_ip", vp_id)
  354. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  355. # Validate bind_ip uniqueness (against all enabled VPs)
  356. existing = await db.execute(
  357. select(VirtualPrinter).where(
  358. VirtualPrinter.bind_ip == vp.bind_ip,
  359. VirtualPrinter.id != vp_id,
  360. VirtualPrinter.enabled == True, # noqa: E712
  361. )
  362. )
  363. conflict = existing.scalar_one_or_none()
  364. if conflict:
  365. logger.warning(
  366. "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
  367. vp_id,
  368. vp.bind_ip,
  369. conflict.id,
  370. conflict.enabled,
  371. conflict.mode,
  372. )
  373. return JSONResponse(
  374. status_code=400,
  375. content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
  376. )
  377. if effective_mode == "proxy":
  378. if not vp.target_printer_id:
  379. logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
  380. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  381. else:
  382. if not vp.access_code:
  383. logger.warning(
  384. "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
  385. )
  386. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  387. elif new_enabled and body.enabled is None:
  388. # VP is already enabled and user is changing other fields —
  389. # auto-disable if new state doesn't meet requirements
  390. if not vp.bind_ip:
  391. new_enabled = False
  392. elif effective_mode == "proxy":
  393. if not vp.target_printer_id:
  394. new_enabled = False
  395. else:
  396. if not vp.access_code:
  397. new_enabled = False
  398. vp.enabled = new_enabled
  399. await db.commit()
  400. await db.refresh(vp)
  401. logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
  402. # Sync services
  403. try:
  404. await virtual_printer_manager.sync_from_db()
  405. except Exception as e:
  406. logger.error("Failed to sync virtual printers after update: %s", e)
  407. instance = virtual_printer_manager.get_instance(vp.id)
  408. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  409. return _vp_to_dict(vp, status)
  410. @router.delete("/{vp_id}")
  411. async def delete_virtual_printer(
  412. vp_id: int,
  413. db: AsyncSession = Depends(get_db),
  414. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  415. ):
  416. """Delete a virtual printer."""
  417. from sqlalchemy import delete as sql_delete
  418. from backend.app.models.virtual_printer import VirtualPrinter
  419. from backend.app.services.virtual_printer import virtual_printer_manager
  420. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  421. vp = result.scalar_one_or_none()
  422. if not vp:
  423. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  424. vp_name = vp.name
  425. # Stop instance if running
  426. await virtual_printer_manager.remove_instance(vp_id)
  427. # Mark any PendingUpload rows that referenced this VP's upload_dir as
  428. # discarded — without this the rows live on as phantom entries in
  429. # /pending-uploads/ pointing at file paths that no longer exist, and
  430. # the user only learns they're orphaned by trying to archive one and
  431. # getting a flip-to-discarded on file-missing.
  432. upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
  433. try:
  434. from backend.app.models.pending_upload import PendingUpload
  435. stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
  436. for pending in stale.scalars().all():
  437. pending.status = "discarded"
  438. await db.flush()
  439. except Exception as e:
  440. logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
  441. # Delete from DB
  442. await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  443. await db.commit()
  444. # Remove the on-disk upload directory after the DB commit succeeds, so
  445. # a crash between commit and rmtree only leaves orphan files (vs orphan
  446. # rows pointing at a now-missing tree).
  447. upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
  448. if upload_dir.exists():
  449. import shutil
  450. shutil.rmtree(upload_dir, ignore_errors=True)
  451. logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
  452. # Resync remaining services
  453. try:
  454. await virtual_printer_manager.sync_from_db()
  455. except Exception as e:
  456. logger.error("Failed to sync virtual printers after delete: %s", e)
  457. return {"detail": "Deleted", "id": vp_id}