virtual_printers.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  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. # Imported at module scope so tests can patch
  12. # backend.app.api.routes.virtual_printers.tailscale_service.
  13. from backend.app.services.virtual_printer.tailscale import tailscale_service
  14. logger = logging.getLogger(__name__)
  15. router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
  16. class TailscaleStatusResponse(BaseModel):
  17. available: bool
  18. fqdn: str
  19. hostname: str
  20. tailnet_name: str
  21. tailscale_ips: list[str]
  22. error: str | None
  23. class VirtualPrinterCreate(BaseModel):
  24. name: str = "Bambuddy"
  25. enabled: bool = False
  26. mode: str = "immediate"
  27. model: str | None = None
  28. access_code: str | None = None
  29. target_printer_id: int | None = None
  30. auto_dispatch: bool = True
  31. queue_force_color_match: bool = False
  32. bind_ip: str | None = None
  33. remote_interface_ip: str | None = None
  34. class VirtualPrinterUpdate(BaseModel):
  35. name: str | None = None
  36. enabled: bool | None = None
  37. mode: str | None = None
  38. model: str | None = None
  39. access_code: str | None = None
  40. target_printer_id: int | None = None
  41. auto_dispatch: bool | None = None
  42. queue_force_color_match: bool | None = None
  43. bind_ip: str | None = None
  44. remote_interface_ip: str | None = None
  45. tailscale_disabled: bool | None = None
  46. def _resolve_printer_model(printer_model: str | None) -> str | None:
  47. """Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
  48. Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.
  49. """
  50. if not printer_model:
  51. return None
  52. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  53. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE
  54. # Already a valid SSDP model code
  55. if printer_model in VIRTUAL_PRINTER_MODELS:
  56. return printer_model
  57. # Map display name to SSDP code
  58. return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
  59. def _vp_to_dict(vp, status: dict | None = None) -> dict:
  60. """Convert VirtualPrinter model to response dict."""
  61. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  62. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
  63. model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
  64. serial = _get_serial_for_model(model_code, vp.serial_suffix)
  65. return {
  66. "id": vp.id,
  67. "name": vp.name,
  68. "enabled": vp.enabled,
  69. "mode": vp.mode,
  70. "model": model_code,
  71. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  72. "access_code_set": bool(vp.access_code),
  73. "serial": serial,
  74. "target_printer_id": vp.target_printer_id,
  75. "auto_dispatch": vp.auto_dispatch,
  76. "queue_force_color_match": vp.queue_force_color_match,
  77. "bind_ip": vp.bind_ip,
  78. "remote_interface_ip": vp.remote_interface_ip,
  79. "tailscale_disabled": vp.tailscale_disabled,
  80. "position": vp.position,
  81. "status": status or {"running": False, "pending_files": 0},
  82. }
  83. @router.get("")
  84. async def list_virtual_printers(
  85. db: AsyncSession = Depends(get_db),
  86. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  87. ):
  88. """List all virtual printers with status."""
  89. from backend.app.models.virtual_printer import VirtualPrinter
  90. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  91. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
  92. vps = result.scalars().all()
  93. printers = []
  94. for vp in vps:
  95. instance = virtual_printer_manager.get_instance(vp.id)
  96. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  97. printers.append(_vp_to_dict(vp, status))
  98. return {
  99. "printers": printers,
  100. "models": VIRTUAL_PRINTER_MODELS,
  101. }
  102. @router.post("")
  103. async def create_virtual_printer(
  104. body: VirtualPrinterCreate,
  105. db: AsyncSession = Depends(get_db),
  106. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  107. ):
  108. """Create a new virtual printer."""
  109. from backend.app.models.virtual_printer import VirtualPrinter
  110. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  111. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
  112. # Validate mode
  113. if body.mode not in ("immediate", "review", "print_queue", "proxy"):
  114. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  115. # Validate model
  116. if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
  117. return JSONResponse(
  118. status_code=400,
  119. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  120. )
  121. # Validate access code length
  122. if body.access_code and len(body.access_code) != 8:
  123. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  124. # Validation when enabling
  125. if body.enabled:
  126. if not body.bind_ip:
  127. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  128. if body.mode == "proxy":
  129. if not body.target_printer_id:
  130. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  131. else:
  132. if not body.access_code:
  133. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  134. # Validate proxy target printer exists
  135. target_printer = None
  136. if body.target_printer_id:
  137. from backend.app.models.printer import Printer
  138. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  139. target_printer = result.scalar_one_or_none()
  140. if not target_printer:
  141. return JSONResponse(
  142. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  143. )
  144. # Validate bind_ip uniqueness (against all enabled VPs)
  145. if body.bind_ip:
  146. result = await db.execute(
  147. select(VirtualPrinter).where(
  148. VirtualPrinter.bind_ip == body.bind_ip,
  149. VirtualPrinter.enabled == True, # noqa: E712
  150. )
  151. )
  152. if result.scalar_one_or_none():
  153. return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
  154. # Generate next serial suffix
  155. result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
  156. last_suffix = result.scalar()
  157. if last_suffix:
  158. try:
  159. next_num = int(last_suffix) + 1
  160. new_suffix = str(next_num).zfill(9)
  161. except ValueError:
  162. new_suffix = "391800002"
  163. else:
  164. new_suffix = "391800001"
  165. # Get next position
  166. result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
  167. last_pos = result.scalar()
  168. next_pos = (last_pos or 0) + 1
  169. vp = VirtualPrinter(
  170. name=body.name,
  171. enabled=body.enabled,
  172. mode=body.mode,
  173. model=body.model
  174. or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
  175. or DEFAULT_VIRTUAL_PRINTER_MODEL,
  176. access_code=body.access_code,
  177. target_printer_id=body.target_printer_id,
  178. auto_dispatch=body.auto_dispatch,
  179. queue_force_color_match=body.queue_force_color_match,
  180. bind_ip=body.bind_ip,
  181. remote_interface_ip=body.remote_interface_ip,
  182. serial_suffix=new_suffix,
  183. position=next_pos,
  184. )
  185. db.add(vp)
  186. await db.commit()
  187. await db.refresh(vp)
  188. logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
  189. # Sync services if enabled
  190. if body.enabled:
  191. try:
  192. await virtual_printer_manager.sync_from_db()
  193. except Exception as e:
  194. logger.error("Failed to start virtual printer after create: %s", e)
  195. return _vp_to_dict(vp)
  196. @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
  197. async def get_tailscale_status(
  198. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  199. ) -> TailscaleStatusResponse:
  200. """Return current Tailscale availability and machine identity.
  201. Used by the frontend to indicate whether virtual printer TLS is backed
  202. by a trusted Let's Encrypt certificate or a self-signed CA.
  203. """
  204. status = await tailscale_service.get_status()
  205. return TailscaleStatusResponse(
  206. available=status.available,
  207. fqdn=status.fqdn,
  208. hostname=status.hostname,
  209. tailnet_name=status.tailnet_name,
  210. tailscale_ips=status.tailscale_ips,
  211. error=status.error,
  212. )
  213. @router.get("/{vp_id}")
  214. async def get_virtual_printer(
  215. vp_id: int,
  216. db: AsyncSession = Depends(get_db),
  217. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  218. ):
  219. """Get a single virtual printer with status."""
  220. from backend.app.models.virtual_printer import VirtualPrinter
  221. from backend.app.services.virtual_printer import virtual_printer_manager
  222. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  223. vp = result.scalar_one_or_none()
  224. if not vp:
  225. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  226. instance = virtual_printer_manager.get_instance(vp.id)
  227. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  228. return _vp_to_dict(vp, status)
  229. @router.put("/{vp_id}")
  230. async def update_virtual_printer(
  231. vp_id: int,
  232. body: VirtualPrinterUpdate,
  233. db: AsyncSession = Depends(get_db),
  234. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  235. ):
  236. """Update a virtual printer."""
  237. from backend.app.models.virtual_printer import VirtualPrinter
  238. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  239. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  240. vp = result.scalar_one_or_none()
  241. if not vp:
  242. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  243. logger.debug(
  244. "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
  245. vp_id,
  246. body.model_dump(exclude_unset=True),
  247. vp.mode,
  248. vp.enabled,
  249. bool(vp.access_code),
  250. vp.bind_ip,
  251. vp.target_printer_id,
  252. )
  253. # Apply updates
  254. if body.name is not None:
  255. vp.name = body.name
  256. if body.mode is not None:
  257. if body.mode not in ("immediate", "review", "print_queue", "proxy"):
  258. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  259. vp.mode = body.mode
  260. if body.model is not None:
  261. if body.model not in VIRTUAL_PRINTER_MODELS:
  262. return JSONResponse(
  263. status_code=400,
  264. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  265. )
  266. vp.model = body.model
  267. if body.access_code is not None:
  268. if body.access_code and len(body.access_code) != 8:
  269. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  270. vp.access_code = body.access_code
  271. if body.target_printer_id is not None:
  272. from backend.app.models.printer import Printer
  273. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  274. target_printer = result.scalar_one_or_none()
  275. if not target_printer:
  276. return JSONResponse(
  277. status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
  278. )
  279. vp.target_printer_id = body.target_printer_id
  280. # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
  281. if body.model is None and vp.mode == "proxy" and target_printer.model:
  282. vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
  283. if body.auto_dispatch is not None:
  284. vp.auto_dispatch = body.auto_dispatch
  285. if body.queue_force_color_match is not None:
  286. vp.queue_force_color_match = body.queue_force_color_match
  287. if body.bind_ip is not None:
  288. vp.bind_ip = body.bind_ip
  289. if body.remote_interface_ip is not None:
  290. vp.remote_interface_ip = body.remote_interface_ip
  291. if body.tailscale_disabled is not None:
  292. # Guard: user trying to enable Tailscale (disabled=False) must have the binary available.
  293. # Otherwise the toggle looks like it works but silently falls back to self-signed.
  294. if body.tailscale_disabled is False and vp.tailscale_disabled is True:
  295. from backend.app.services.virtual_printer.tailscale import tailscale_service
  296. ts_status = await tailscale_service.get_status()
  297. if not ts_status.available:
  298. return JSONResponse(
  299. status_code=409,
  300. content={
  301. "detail": "tailscale_not_available",
  302. "reason": ts_status.error or "tailscale binary not found",
  303. },
  304. )
  305. vp.tailscale_disabled = body.tailscale_disabled
  306. # Auto-inherit model when switching to proxy mode with existing target printer
  307. if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
  308. from backend.app.models.printer import Printer as PrinterModel
  309. result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
  310. existing_target = result.scalar_one_or_none()
  311. if existing_target and existing_target.model:
  312. vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
  313. # Determine final enabled state
  314. explicitly_enabling = body.enabled is True
  315. new_enabled = body.enabled if body.enabled is not None else vp.enabled
  316. effective_mode = vp.mode
  317. if explicitly_enabling:
  318. # User is explicitly toggling on — enforce all requirements
  319. if not vp.bind_ip:
  320. logger.warning("Update VP %d rejected: no bind_ip", vp_id)
  321. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  322. # Validate bind_ip uniqueness (against all enabled VPs)
  323. existing = await db.execute(
  324. select(VirtualPrinter).where(
  325. VirtualPrinter.bind_ip == vp.bind_ip,
  326. VirtualPrinter.id != vp_id,
  327. VirtualPrinter.enabled == True, # noqa: E712
  328. )
  329. )
  330. conflict = existing.scalar_one_or_none()
  331. if conflict:
  332. logger.warning(
  333. "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
  334. vp_id,
  335. vp.bind_ip,
  336. conflict.id,
  337. conflict.enabled,
  338. conflict.mode,
  339. )
  340. return JSONResponse(
  341. status_code=400,
  342. content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
  343. )
  344. if effective_mode == "proxy":
  345. if not vp.target_printer_id:
  346. logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
  347. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  348. else:
  349. if not vp.access_code:
  350. logger.warning(
  351. "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
  352. )
  353. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  354. elif new_enabled and body.enabled is None:
  355. # VP is already enabled and user is changing other fields —
  356. # auto-disable if new state doesn't meet requirements
  357. if not vp.bind_ip:
  358. new_enabled = False
  359. elif effective_mode == "proxy":
  360. if not vp.target_printer_id:
  361. new_enabled = False
  362. else:
  363. if not vp.access_code:
  364. new_enabled = False
  365. vp.enabled = new_enabled
  366. await db.commit()
  367. await db.refresh(vp)
  368. logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
  369. # Sync services
  370. try:
  371. await virtual_printer_manager.sync_from_db()
  372. except Exception as e:
  373. logger.error("Failed to sync virtual printers after update: %s", e)
  374. instance = virtual_printer_manager.get_instance(vp.id)
  375. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  376. return _vp_to_dict(vp, status)
  377. @router.delete("/{vp_id}")
  378. async def delete_virtual_printer(
  379. vp_id: int,
  380. db: AsyncSession = Depends(get_db),
  381. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  382. ):
  383. """Delete a virtual printer."""
  384. from sqlalchemy import delete as sql_delete
  385. from backend.app.models.virtual_printer import VirtualPrinter
  386. from backend.app.services.virtual_printer import virtual_printer_manager
  387. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  388. vp = result.scalar_one_or_none()
  389. if not vp:
  390. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  391. vp_name = vp.name
  392. # Stop instance if running
  393. await virtual_printer_manager.remove_instance(vp_id)
  394. # Delete from DB
  395. await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  396. await db.commit()
  397. logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
  398. # Resync remaining services
  399. try:
  400. await virtual_printer_manager.sync_from_db()
  401. except Exception as e:
  402. logger.error("Failed to sync virtual printers after delete: %s", e)
  403. return {"detail": "Deleted", "id": vp_id}