ftp_server.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. """Implicit FTPS server for receiving 3MF uploads from slicers.
  2. Implements an implicit FTPS server (TLS from byte 0) that accepts file uploads
  3. from Bambu Studio and OrcaSlicer, matching the real Bambu printer behavior.
  4. Unlike explicit FTPS (AUTH TLS), implicit FTPS wraps the connection in TLS
  5. immediately upon connection, before any FTP commands are exchanged.
  6. """
  7. import asyncio
  8. import logging
  9. import random
  10. import ssl
  11. from collections.abc import Callable
  12. from pathlib import Path
  13. logger = logging.getLogger(__name__)
  14. # Default FTP port for Bambu printers (implicit FTPS)
  15. FTP_PORT = 9990
  16. class FTPSession:
  17. """Handles a single FTP client session."""
  18. def __init__(
  19. self,
  20. reader: asyncio.StreamReader,
  21. writer: asyncio.StreamWriter,
  22. upload_dir: Path,
  23. access_code: str,
  24. ssl_context: ssl.SSLContext,
  25. on_file_received: Callable[[Path, str], None] | None,
  26. ):
  27. self.reader = reader
  28. self.writer = writer
  29. self.upload_dir = upload_dir
  30. self.access_code = access_code
  31. self.ssl_context = ssl_context
  32. self.on_file_received = on_file_received
  33. self.authenticated = False
  34. self.username: str | None = None
  35. self.current_dir = upload_dir
  36. self.transfer_type = "A" # ASCII by default
  37. self.data_server: asyncio.Server | None = None
  38. self.data_port: int | None = None
  39. # For data transfer coordination
  40. self._data_reader: asyncio.StreamReader | None = None
  41. self._data_writer: asyncio.StreamWriter | None = None
  42. self._data_connected = asyncio.Event()
  43. peername = writer.get_extra_info("peername")
  44. self.remote_ip = peername[0] if peername else "unknown"
  45. async def send(self, code: int, message: str) -> None:
  46. """Send an FTP response."""
  47. response = f"{code} {message}\r\n"
  48. logger.info("FTP -> %s: %s", self.remote_ip, response.strip())
  49. self.writer.write(response.encode("utf-8"))
  50. await self.writer.drain()
  51. async def handle(self) -> None:
  52. """Handle the FTP session."""
  53. try:
  54. # Send welcome banner
  55. await self.send(220, "Bambuddy Virtual Printer FTP ready")
  56. while True:
  57. try:
  58. line = await asyncio.wait_for(
  59. self.reader.readline(),
  60. timeout=300, # 5 minute timeout
  61. )
  62. except TimeoutError:
  63. logger.debug("FTP session timeout from %s", self.remote_ip)
  64. break
  65. if not line:
  66. break
  67. try:
  68. command_line = line.decode("utf-8").strip()
  69. except UnicodeDecodeError:
  70. command_line = line.decode("latin-1").strip()
  71. if not command_line:
  72. continue
  73. logger.info("FTP <- %s: %s", self.remote_ip, command_line)
  74. # Parse command and argument
  75. parts = command_line.split(" ", 1)
  76. cmd = parts[0].upper()
  77. arg = parts[1] if len(parts) > 1 else ""
  78. # Dispatch command
  79. handler = getattr(self, f"cmd_{cmd}", None)
  80. if handler:
  81. await handler(arg)
  82. else:
  83. logger.warning("FTP command not implemented: %s", cmd)
  84. await self.send(502, f"Command {cmd} not implemented")
  85. except asyncio.CancelledError:
  86. logger.info("FTP session cancelled from %s", self.remote_ip)
  87. except Exception as e:
  88. logger.error("FTP session error from %s: %s", self.remote_ip, e)
  89. finally:
  90. logger.info("FTP session ended from %s", self.remote_ip)
  91. await self._cleanup()
  92. async def _cleanup(self) -> None:
  93. """Clean up session resources."""
  94. if self.data_server:
  95. self.data_server.close()
  96. try:
  97. await self.data_server.wait_closed()
  98. except OSError:
  99. pass
  100. self.data_server = None
  101. try:
  102. self.writer.close()
  103. await self.writer.wait_closed()
  104. except OSError:
  105. pass
  106. # FTP Commands
  107. async def cmd_USER(self, arg: str) -> None:
  108. """Handle USER command."""
  109. self.username = arg
  110. if arg.lower() == "bblp":
  111. await self.send(331, "Password required")
  112. else:
  113. await self.send(530, "Invalid user")
  114. async def cmd_PASS(self, arg: str) -> None:
  115. """Handle PASS command."""
  116. if self.username and self.username.lower() == "bblp":
  117. if arg == self.access_code:
  118. self.authenticated = True
  119. await self.send(230, "Login successful")
  120. logger.info("FTP login from %s", self.remote_ip)
  121. else:
  122. await self.send(530, "Login incorrect")
  123. logger.warning("FTP failed login from %s", self.remote_ip)
  124. else:
  125. await self.send(503, "Login with USER first")
  126. async def cmd_SYST(self, arg: str) -> None:
  127. """Handle SYST command."""
  128. await self.send(215, "UNIX Type: L8")
  129. async def cmd_FEAT(self, arg: str) -> None:
  130. """Handle FEAT command."""
  131. features = [
  132. "211-Features:",
  133. " PASV",
  134. " UTF8",
  135. " SIZE",
  136. "211 End",
  137. ]
  138. for line in features[:-1]:
  139. self.writer.write(f"{line}\r\n".encode())
  140. await self.writer.drain()
  141. self.writer.write(f"{features[-1]}\r\n".encode())
  142. await self.writer.drain()
  143. async def cmd_PWD(self, arg: str) -> None:
  144. """Handle PWD command."""
  145. if not self.authenticated:
  146. await self.send(530, "Not logged in")
  147. return
  148. await self.send(257, '"/" is current directory')
  149. async def cmd_CWD(self, arg: str) -> None:
  150. """Handle CWD command."""
  151. if not self.authenticated:
  152. await self.send(530, "Not logged in")
  153. return
  154. # Accept any directory change (we use a flat structure)
  155. await self.send(250, "Directory changed")
  156. async def cmd_TYPE(self, arg: str) -> None:
  157. """Handle TYPE command."""
  158. if not self.authenticated:
  159. await self.send(530, "Not logged in")
  160. return
  161. if arg.upper() in ("A", "I"):
  162. self.transfer_type = arg.upper()
  163. type_name = "ASCII" if arg.upper() == "A" else "Binary"
  164. await self.send(200, f"Type set to {type_name}")
  165. else:
  166. await self.send(504, "Type not supported")
  167. async def cmd_EPSV(self, arg: str) -> None:
  168. """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
  169. if not self.authenticated:
  170. await self.send(530, "Not logged in")
  171. return
  172. # Close any existing data connection/server
  173. await self._close_data_connection()
  174. # Reset connection state
  175. self._data_connected.clear()
  176. self._data_reader = None
  177. self._data_writer = None
  178. # Find a free port for passive data connection
  179. self.data_port = random.randint(50000, 60000)
  180. try:
  181. # Create data server with TLS - use same context for session reuse
  182. self.data_server = await asyncio.start_server(
  183. self._handle_data_connection,
  184. "0.0.0.0",
  185. self.data_port,
  186. ssl=self.ssl_context,
  187. )
  188. # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
  189. await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
  190. logger.info("FTP EPSV listening on port %s", self.data_port)
  191. except Exception as e:
  192. logger.error("Failed to create EPSV data connection: %s", e)
  193. await self.send(425, "Cannot open data connection")
  194. async def cmd_PASV(self, arg: str) -> None:
  195. """Handle PASV command - set up passive data connection."""
  196. if not self.authenticated:
  197. await self.send(530, "Not logged in")
  198. return
  199. # Close any existing data connection/server
  200. await self._close_data_connection()
  201. # Reset connection state
  202. self._data_connected.clear()
  203. self._data_reader = None
  204. self._data_writer = None
  205. # Find a free port for passive data connection
  206. self.data_port = random.randint(50000, 60000)
  207. try:
  208. # Create data server with TLS
  209. self.data_server = await asyncio.start_server(
  210. self._handle_data_connection,
  211. "0.0.0.0",
  212. self.data_port,
  213. ssl=self.ssl_context,
  214. )
  215. # Get server's IP for response
  216. # Use the IP the client connected to
  217. sockname = self.writer.get_extra_info("sockname")
  218. ip = sockname[0] if sockname else "127.0.0.1"
  219. # Format IP and port for PASV response
  220. ip_parts = ip.split(".")
  221. port_hi = self.data_port // 256
  222. port_lo = self.data_port % 256
  223. await self.send(
  224. 227,
  225. f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
  226. )
  227. logger.info("FTP PASV listening on port %s", self.data_port)
  228. except Exception as e:
  229. logger.error("Failed to create passive data connection: %s", e)
  230. await self.send(425, "Cannot open data connection")
  231. async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  232. """Handle incoming data connection (used by PASV)."""
  233. # Log TLS details for debugging
  234. ssl_obj = writer.get_extra_info("ssl_object")
  235. if ssl_obj:
  236. logger.info(
  237. f"FTP data TLS from {self.remote_ip}: cipher={ssl_obj.cipher()}, "
  238. f"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}"
  239. )
  240. else:
  241. logger.warning("FTP data connection from %s has no SSL!", self.remote_ip)
  242. logger.info("FTP data connection established from %s", self.remote_ip)
  243. self._data_reader = reader
  244. self._data_writer = writer
  245. self._data_connected.set()
  246. # Don't close - let the transfer command handle it
  247. async def _close_data_connection(self) -> None:
  248. """Close the data connection and server."""
  249. had_connection = self._data_writer is not None or self.data_server is not None
  250. if self._data_writer:
  251. try:
  252. self._data_writer.close()
  253. await self._data_writer.wait_closed()
  254. except OSError:
  255. pass
  256. self._data_writer = None
  257. self._data_reader = None
  258. if self.data_server:
  259. try:
  260. self.data_server.close()
  261. await self.data_server.wait_closed()
  262. except OSError:
  263. pass
  264. self.data_server = None
  265. # Only delay if we actually closed something
  266. if had_connection:
  267. await asyncio.sleep(0.1)
  268. async def cmd_STOR(self, arg: str) -> None:
  269. """Handle STOR command - receive file upload."""
  270. if not self.authenticated:
  271. await self.send(530, "Not logged in")
  272. return
  273. if not self.data_server:
  274. await self.send(425, "Use PASV first")
  275. return
  276. filename = Path(arg).name # Sanitize filename
  277. file_path = self.upload_dir / filename
  278. logger.info("FTP receiving file: %s from %s", filename, self.remote_ip)
  279. await self.send(150, f"Opening data connection for {filename}")
  280. # Wait for data connection to be established (client connects after 150)
  281. try:
  282. await asyncio.wait_for(self._data_connected.wait(), timeout=30)
  283. except TimeoutError:
  284. logger.error("FTP data connection timeout - client didn't connect")
  285. await self.send(425, "Data connection timeout")
  286. await self._close_data_connection()
  287. return
  288. if not self._data_reader:
  289. await self.send(425, "Data connection failed")
  290. await self._close_data_connection()
  291. return
  292. # Receive data
  293. data_content: list[bytes] = []
  294. try:
  295. while True:
  296. chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
  297. if not chunk:
  298. break
  299. data_content.append(chunk)
  300. logger.debug("FTP received chunk: %s bytes", len(chunk))
  301. except TimeoutError:
  302. logger.error("FTP data transfer timeout")
  303. await self.send(426, "Transfer timeout")
  304. await self._close_data_connection()
  305. return
  306. except Exception as e:
  307. logger.error("FTP data transfer error: %s", e)
  308. await self.send(426, f"Transfer failed: {e}")
  309. await self._close_data_connection()
  310. return
  311. # Close data connection
  312. await self._close_data_connection()
  313. # Write file
  314. try:
  315. total_size = sum(len(c) for c in data_content)
  316. file_path.write_bytes(b"".join(data_content))
  317. logger.info("FTP saved file: %s (%s bytes)", file_path, total_size)
  318. await self.send(226, "Transfer complete")
  319. # Notify callback
  320. if self.on_file_received:
  321. try:
  322. result = self.on_file_received(file_path, self.remote_ip)
  323. if asyncio.iscoroutine(result):
  324. await result
  325. except Exception as e:
  326. logger.error("File received callback error: %s", e)
  327. except Exception as e:
  328. logger.error("Failed to save file %s: %s", file_path, e)
  329. await self.send(550, "Failed to save file")
  330. async def cmd_SIZE(self, arg: str) -> None:
  331. """Handle SIZE command."""
  332. if not self.authenticated:
  333. await self.send(530, "Not logged in")
  334. return
  335. # We don't store files for SIZE queries
  336. await self.send(550, "File not found")
  337. async def cmd_QUIT(self, arg: str) -> None:
  338. """Handle QUIT command."""
  339. await self.send(221, "Goodbye")
  340. raise asyncio.CancelledError()
  341. async def cmd_NOOP(self, arg: str) -> None:
  342. """Handle NOOP command."""
  343. await self.send(200, "OK")
  344. async def cmd_OPTS(self, arg: str) -> None:
  345. """Handle OPTS command."""
  346. if arg.upper().startswith("UTF8"):
  347. await self.send(200, "UTF8 mode enabled")
  348. else:
  349. await self.send(501, "Option not supported")
  350. async def cmd_PBSZ(self, arg: str) -> None:
  351. """Handle PBSZ (Protection Buffer Size) command.
  352. Required for FTP security extensions. With TLS, buffer size is 0.
  353. """
  354. await self.send(200, "PBSZ=0")
  355. async def cmd_PROT(self, arg: str) -> None:
  356. """Handle PROT (Data Channel Protection Level) command.
  357. P = Private (encrypted), which we always use with implicit FTPS.
  358. """
  359. if arg.upper() == "P":
  360. await self.send(200, "Protection level set to Private")
  361. elif arg.upper() == "C":
  362. # Clear (unprotected) - we don't support this
  363. await self.send(536, "Protection level C not supported")
  364. else:
  365. await self.send(504, f"Protection level {arg} not supported")
  366. async def cmd_MKD(self, arg: str) -> None:
  367. """Handle MKD (Make Directory) command."""
  368. if not self.authenticated:
  369. await self.send(530, "Not logged in")
  370. return
  371. # We don't really create directories, just pretend it works
  372. await self.send(257, f'"{arg}" directory created')
  373. async def cmd_LIST(self, arg: str) -> None:
  374. """Handle LIST command - list directory contents."""
  375. if not self.authenticated:
  376. await self.send(530, "Not logged in")
  377. return
  378. # We don't support listing, return empty
  379. await self.send(150, "Opening data connection")
  380. await self.send(226, "Transfer complete")
  381. class VirtualPrinterFTPServer:
  382. """Implicit FTPS server that accepts uploads from slicers."""
  383. def __init__(
  384. self,
  385. upload_dir: Path,
  386. access_code: str,
  387. cert_path: Path,
  388. key_path: Path,
  389. port: int = FTP_PORT,
  390. on_file_received: Callable[[Path, str], None] | None = None,
  391. ):
  392. """Initialize the FTPS server.
  393. Args:
  394. upload_dir: Directory to store uploaded files
  395. access_code: Password for authentication (bblp user)
  396. cert_path: Path to TLS certificate file
  397. key_path: Path to TLS private key file
  398. port: Port to listen on (default 990)
  399. on_file_received: Callback when file upload completes (path, source_ip)
  400. """
  401. self.upload_dir = upload_dir
  402. self.access_code = access_code
  403. self.cert_path = cert_path
  404. self.key_path = key_path
  405. self.port = port
  406. self.on_file_received = on_file_received
  407. self._server: asyncio.Server | None = None
  408. self._running = False
  409. self._ssl_context: ssl.SSLContext | None = None
  410. self._active_sessions: list[asyncio.Task] = []
  411. async def start(self) -> None:
  412. """Start the implicit FTPS server."""
  413. if self._running:
  414. return
  415. logger.info("Starting virtual printer implicit FTPS on port %s", self.port)
  416. # Ensure upload directory exists
  417. self.upload_dir.mkdir(parents=True, exist_ok=True)
  418. cache_dir = self.upload_dir / "cache"
  419. cache_dir.mkdir(exist_ok=True)
  420. # Create SSL context for implicit FTPS (TLS from byte 0)
  421. self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  422. self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
  423. self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
  424. # Use standard TLS settings for compatibility
  425. self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
  426. logger.info("FTP SSL context created with standard settings")
  427. try:
  428. # Create server with SSL - TLS handshake happens before any FTP data
  429. self._server = await asyncio.start_server(
  430. self._handle_client,
  431. "0.0.0.0",
  432. self.port,
  433. ssl=self._ssl_context, # This makes it implicit FTPS!
  434. )
  435. self._running = True
  436. logger.info("Implicit FTPS server started on port %s", self.port)
  437. async with self._server:
  438. await self._server.serve_forever()
  439. except OSError as e:
  440. if e.errno == 98: # Address already in use
  441. logger.error("FTP port %s is already in use", self.port)
  442. else:
  443. logger.error("FTP server error: %s", e)
  444. except asyncio.CancelledError:
  445. logger.debug("FTP server task cancelled")
  446. except Exception as e:
  447. logger.error("FTP server error: %s", e)
  448. finally:
  449. await self.stop()
  450. async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  451. """Handle a new FTP client connection."""
  452. peername = writer.get_extra_info("peername")
  453. logger.info("FTP connection from %s", peername)
  454. session = FTPSession(
  455. reader=reader,
  456. writer=writer,
  457. upload_dir=self.upload_dir,
  458. access_code=self.access_code,
  459. ssl_context=self._ssl_context,
  460. on_file_received=self.on_file_received,
  461. )
  462. # Track the session task so we can cancel it on stop
  463. task = asyncio.current_task()
  464. if task:
  465. self._active_sessions.append(task)
  466. try:
  467. await session.handle()
  468. finally:
  469. if task and task in self._active_sessions:
  470. self._active_sessions.remove(task)
  471. async def stop(self) -> None:
  472. """Stop the FTPS server."""
  473. logger.info("Stopping FTP server")
  474. self._running = False
  475. # Cancel all active sessions first
  476. for task in self._active_sessions[:]: # Copy list to avoid modification during iteration
  477. task.cancel()
  478. # Wait briefly for sessions to clean up
  479. if self._active_sessions:
  480. await asyncio.sleep(0.1)
  481. self._active_sessions.clear()
  482. if self._server:
  483. try:
  484. self._server.close()
  485. await self._server.wait_closed()
  486. except OSError as e:
  487. logger.debug("Error closing FTP server: %s", e)
  488. self._server = None