ftp_server.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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(f"FTP -> {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(f"FTP session timeout from {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(f"FTP <- {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(f"FTP command not implemented: {cmd}")
  84. await self.send(502, f"Command {cmd} not implemented")
  85. except asyncio.CancelledError:
  86. logger.info(f"FTP session cancelled from {self.remote_ip}")
  87. except Exception as e:
  88. logger.error(f"FTP session error from {self.remote_ip}: {e}")
  89. finally:
  90. logger.info(f"FTP session ended from {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 Exception:
  99. pass
  100. self.data_server = None
  101. try:
  102. self.writer.close()
  103. await self.writer.wait_closed()
  104. except Exception:
  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(f"FTP login from {self.remote_ip}")
  121. else:
  122. await self.send(530, "Login incorrect")
  123. logger.warning(f"FTP failed login from {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_PASV(self, arg: str) -> None:
  168. """Handle PASV command - set up passive data connection."""
  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
  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. # Get server's IP for response
  189. # Use the IP the client connected to
  190. sockname = self.writer.get_extra_info("sockname")
  191. ip = sockname[0] if sockname else "127.0.0.1"
  192. # Format IP and port for PASV response
  193. ip_parts = ip.split(".")
  194. port_hi = self.data_port // 256
  195. port_lo = self.data_port % 256
  196. await self.send(
  197. 227,
  198. f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
  199. )
  200. logger.info(f"FTP PASV listening on port {self.data_port}")
  201. except Exception as e:
  202. logger.error(f"Failed to create passive data connection: {e}")
  203. await self.send(425, "Cannot open data connection")
  204. async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  205. """Handle incoming data connection (used by PASV)."""
  206. logger.info(f"FTP data connection established from {self.remote_ip}")
  207. self._data_reader = reader
  208. self._data_writer = writer
  209. self._data_connected.set()
  210. # Don't close - let the transfer command handle it
  211. async def _close_data_connection(self) -> None:
  212. """Close the data connection and server."""
  213. if self._data_writer:
  214. try:
  215. self._data_writer.close()
  216. await self._data_writer.wait_closed()
  217. except Exception:
  218. pass
  219. self._data_writer = None
  220. self._data_reader = None
  221. if self.data_server:
  222. try:
  223. self.data_server.close()
  224. await self.data_server.wait_closed()
  225. except Exception:
  226. pass
  227. self.data_server = None
  228. async def cmd_STOR(self, arg: str) -> None:
  229. """Handle STOR command - receive file upload."""
  230. if not self.authenticated:
  231. await self.send(530, "Not logged in")
  232. return
  233. if not self.data_server:
  234. await self.send(425, "Use PASV first")
  235. return
  236. filename = Path(arg).name # Sanitize filename
  237. file_path = self.upload_dir / filename
  238. logger.info(f"FTP receiving file: {filename} from {self.remote_ip}")
  239. await self.send(150, f"Opening data connection for {filename}")
  240. # Wait for data connection to be established (client connects after 150)
  241. try:
  242. await asyncio.wait_for(self._data_connected.wait(), timeout=30)
  243. except TimeoutError:
  244. logger.error("FTP data connection timeout - client didn't connect")
  245. await self.send(425, "Data connection timeout")
  246. await self._close_data_connection()
  247. return
  248. if not self._data_reader:
  249. await self.send(425, "Data connection failed")
  250. await self._close_data_connection()
  251. return
  252. # Receive data
  253. data_content: list[bytes] = []
  254. try:
  255. while True:
  256. chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)
  257. if not chunk:
  258. break
  259. data_content.append(chunk)
  260. logger.debug(f"FTP received chunk: {len(chunk)} bytes")
  261. except TimeoutError:
  262. logger.error("FTP data transfer timeout")
  263. await self.send(426, "Transfer timeout")
  264. await self._close_data_connection()
  265. return
  266. except Exception as e:
  267. logger.error(f"FTP data transfer error: {e}")
  268. await self.send(426, f"Transfer failed: {e}")
  269. await self._close_data_connection()
  270. return
  271. # Close data connection
  272. await self._close_data_connection()
  273. # Write file
  274. try:
  275. total_size = sum(len(c) for c in data_content)
  276. file_path.write_bytes(b"".join(data_content))
  277. logger.info(f"FTP saved file: {file_path} ({total_size} bytes)")
  278. await self.send(226, "Transfer complete")
  279. # Notify callback
  280. if self.on_file_received:
  281. try:
  282. result = self.on_file_received(file_path, self.remote_ip)
  283. if asyncio.iscoroutine(result):
  284. await result
  285. except Exception as e:
  286. logger.error(f"File received callback error: {e}")
  287. except Exception as e:
  288. logger.error(f"Failed to save file {file_path}: {e}")
  289. await self.send(550, "Failed to save file")
  290. async def cmd_SIZE(self, arg: str) -> None:
  291. """Handle SIZE command."""
  292. if not self.authenticated:
  293. await self.send(530, "Not logged in")
  294. return
  295. # We don't store files for SIZE queries
  296. await self.send(550, "File not found")
  297. async def cmd_QUIT(self, arg: str) -> None:
  298. """Handle QUIT command."""
  299. await self.send(221, "Goodbye")
  300. raise asyncio.CancelledError()
  301. async def cmd_NOOP(self, arg: str) -> None:
  302. """Handle NOOP command."""
  303. await self.send(200, "OK")
  304. async def cmd_OPTS(self, arg: str) -> None:
  305. """Handle OPTS command."""
  306. if arg.upper().startswith("UTF8"):
  307. await self.send(200, "UTF8 mode enabled")
  308. else:
  309. await self.send(501, "Option not supported")
  310. async def cmd_PBSZ(self, arg: str) -> None:
  311. """Handle PBSZ (Protection Buffer Size) command.
  312. Required for FTP security extensions. With TLS, buffer size is 0.
  313. """
  314. await self.send(200, "PBSZ=0")
  315. async def cmd_PROT(self, arg: str) -> None:
  316. """Handle PROT (Data Channel Protection Level) command.
  317. P = Private (encrypted), which we always use with implicit FTPS.
  318. """
  319. if arg.upper() == "P":
  320. await self.send(200, "Protection level set to Private")
  321. elif arg.upper() == "C":
  322. # Clear (unprotected) - we don't support this
  323. await self.send(536, "Protection level C not supported")
  324. else:
  325. await self.send(504, f"Protection level {arg} not supported")
  326. async def cmd_MKD(self, arg: str) -> None:
  327. """Handle MKD (Make Directory) command."""
  328. if not self.authenticated:
  329. await self.send(530, "Not logged in")
  330. return
  331. # We don't really create directories, just pretend it works
  332. await self.send(257, f'"{arg}" directory created')
  333. async def cmd_LIST(self, arg: str) -> None:
  334. """Handle LIST command - list directory contents."""
  335. if not self.authenticated:
  336. await self.send(530, "Not logged in")
  337. return
  338. # We don't support listing, return empty
  339. await self.send(150, "Opening data connection")
  340. await self.send(226, "Transfer complete")
  341. class VirtualPrinterFTPServer:
  342. """Implicit FTPS server that accepts uploads from slicers."""
  343. def __init__(
  344. self,
  345. upload_dir: Path,
  346. access_code: str,
  347. cert_path: Path,
  348. key_path: Path,
  349. port: int = FTP_PORT,
  350. on_file_received: Callable[[Path, str], None] | None = None,
  351. ):
  352. """Initialize the FTPS server.
  353. Args:
  354. upload_dir: Directory to store uploaded files
  355. access_code: Password for authentication (bblp user)
  356. cert_path: Path to TLS certificate file
  357. key_path: Path to TLS private key file
  358. port: Port to listen on (default 990)
  359. on_file_received: Callback when file upload completes (path, source_ip)
  360. """
  361. self.upload_dir = upload_dir
  362. self.access_code = access_code
  363. self.cert_path = cert_path
  364. self.key_path = key_path
  365. self.port = port
  366. self.on_file_received = on_file_received
  367. self._server: asyncio.Server | None = None
  368. self._running = False
  369. self._ssl_context: ssl.SSLContext | None = None
  370. async def start(self) -> None:
  371. """Start the implicit FTPS server."""
  372. if self._running:
  373. return
  374. logger.info(f"Starting virtual printer implicit FTPS on port {self.port}")
  375. # Ensure upload directory exists
  376. self.upload_dir.mkdir(parents=True, exist_ok=True)
  377. cache_dir = self.upload_dir / "cache"
  378. cache_dir.mkdir(exist_ok=True)
  379. # Create SSL context for implicit FTPS (TLS from byte 0)
  380. self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  381. self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
  382. self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
  383. try:
  384. # Create server with SSL - TLS handshake happens before any FTP data
  385. self._server = await asyncio.start_server(
  386. self._handle_client,
  387. "0.0.0.0",
  388. self.port,
  389. ssl=self._ssl_context, # This makes it implicit FTPS!
  390. )
  391. self._running = True
  392. logger.info(f"Implicit FTPS server started on port {self.port}")
  393. async with self._server:
  394. await self._server.serve_forever()
  395. except OSError as e:
  396. if e.errno == 98: # Address already in use
  397. logger.error(f"FTP port {self.port} is already in use")
  398. else:
  399. logger.error(f"FTP server error: {e}")
  400. except asyncio.CancelledError:
  401. logger.debug("FTP server task cancelled")
  402. except Exception as e:
  403. logger.error(f"FTP server error: {e}")
  404. finally:
  405. await self.stop()
  406. async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  407. """Handle a new FTP client connection."""
  408. peername = writer.get_extra_info("peername")
  409. logger.info(f"FTP connection from {peername}")
  410. session = FTPSession(
  411. reader=reader,
  412. writer=writer,
  413. upload_dir=self.upload_dir,
  414. access_code=self.access_code,
  415. ssl_context=self._ssl_context,
  416. on_file_received=self.on_file_received,
  417. )
  418. await session.handle()
  419. async def stop(self) -> None:
  420. """Stop the FTPS server."""
  421. logger.info("Stopping FTP server")
  422. self._running = False
  423. if self._server:
  424. try:
  425. self._server.close()
  426. await self._server.wait_closed()
  427. except Exception as e:
  428. logger.debug(f"Error closing FTP server: {e}")
  429. self._server = None