mirror of
https://github.com/ClusterM/open-bamboo-networking.git
synced 2026-10-22 19:12:36 -05:00
1067 lines
36 KiB
Python
Executable File
1067 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""bambu_ftp_proxy.py - Plaintext FTP <-> Bambu printer FTPS bridge.
|
|
|
|
Bambu printers expose a quirky implicit-FTPS daemon on TCP/990 that most
|
|
non-FileZilla FTP clients cannot talk to. See `include/obn/ftps.hpp` and
|
|
`src/ftps.cpp` in this repo for the full list of quirks; the relevant
|
|
ones for this proxy are:
|
|
|
|
* Implicit TLS: handshake starts immediately on TCP connect; there is
|
|
no `AUTH TLS` command.
|
|
* Self-signed cert with no usable SAN -> we run with verify=NONE.
|
|
* Login: `USER bblp` / `PASS <printer access code>`.
|
|
* Data channel: PASV only. The PASV reply contains an unreachable IP
|
|
(often 0.0.0.0); we discard it and reconnect to the control host.
|
|
* Data TLS handshake is delayed: send command -> read 150 -> only
|
|
then perform TLS on the data socket. Where supported we reuse the
|
|
control session.
|
|
* MLSD is NOT implemented; only `ls -l`-style LIST works.
|
|
|
|
This script accepts plaintext FTP from any client on `--listen-host:
|
|
--listen-port` (default 127.0.0.1:2121) and translates each session to
|
|
the printer's FTPS dialect. One plaintext client = one FTPS session to
|
|
the printer.
|
|
|
|
Two credential modes:
|
|
* If `access_code` is given on the command line, every plaintext
|
|
client is silently logged in to the printer with that code; the
|
|
USER/PASS the client sends is ignored.
|
|
* If `access_code` is omitted, the proxy runs in pass-through mode:
|
|
the username and password the FTP client sends in USER/PASS are
|
|
used verbatim as `USER bblp` / `PASS <code>` against the printer.
|
|
Wrong code -> 530 from the proxy, the client may retry.
|
|
|
|
Usage:
|
|
python3 bambu_ftp_proxy.py <printer_ip> [<access_code>] [-v]
|
|
lftp -u bblp,<access_code> ftp://127.0.0.1:2121
|
|
curl --user bblp:<access_code> ftp://127.0.0.1:2121/
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
from typing import Callable, Dict, Optional, Tuple
|
|
|
|
LOG = logging.getLogger("bambu_ftp_proxy")
|
|
|
|
# Matches the "(h1,h2,h3,h4,p1,p2)" tuple in a PASV reply.
|
|
PASV_RE = re.compile(r"\((\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\)")
|
|
|
|
# FTP commands that need a prepared PASV/EPSV listener and stream data.
|
|
DATA_COMMANDS = {"LIST", "NLST", "RETR", "STOR"}
|
|
|
|
# Commands the proxy is happy to acknowledge before the printer-side
|
|
# session is established.
|
|
PRELOGIN_COMMANDS = {"USER", "PASS", "QUIT", "FEAT", "OPTS", "SYST", "NOOP"}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Printer-side FTPS client
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
class PrinterError(Exception):
|
|
"""Raised when the printer FTPS session is unusable."""
|
|
|
|
|
|
class PrinterFtps:
|
|
"""Minimal implicit-FTPS client tailored to Bambu printer quirks.
|
|
|
|
Mirrors the C++ obn::ftps::Client in `src/ftps.cpp` closely enough
|
|
that the same printers work without any additional probing.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
port: int,
|
|
username: str,
|
|
password: str,
|
|
control_timeout: float,
|
|
data_timeout: float,
|
|
) -> None:
|
|
self.host = host
|
|
self.port = port
|
|
self.username = username
|
|
self.password = password
|
|
self.control_timeout = control_timeout
|
|
self.data_timeout = data_timeout
|
|
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
# Bambu firmware negotiates TLS 1.2 (see src/ftps.cpp). Rely on
|
|
# the SSL library default minimum (TLS 1.2+); do not set
|
|
# minimum_version to TLS 1.0/1.1 -- Python 3.13+ deprecates that.
|
|
# OpenSSL 3.x rejects servers that don't support secure
|
|
# renegotiation by default. Bambu's vsftpd does, so this is
|
|
# mostly defensive.
|
|
try:
|
|
ctx.options |= getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4)
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
self._ctx = ctx
|
|
|
|
self._ctrl: Optional[ssl.SSLSocket] = None
|
|
self._buf = b""
|
|
|
|
# ---- control channel ---------------------------------------------
|
|
|
|
def connect(self) -> None:
|
|
"""TCP-connects to the printer, performs implicit TLS, logs in."""
|
|
raw = socket.create_connection((self.host, self.port), timeout=self.control_timeout)
|
|
raw.settimeout(self.control_timeout)
|
|
try:
|
|
self._ctrl = self._ctx.wrap_socket(raw, server_hostname=self.host)
|
|
except (ssl.SSLError, OSError) as e:
|
|
try:
|
|
raw.close()
|
|
except OSError:
|
|
pass
|
|
raise PrinterError(f"TLS handshake to {self.host}:{self.port} failed: {e}") from e
|
|
|
|
code, body = self._read_reply()
|
|
if code != 220:
|
|
raise PrinterError(f"no 220 banner: {code} {body!r}")
|
|
LOG.debug("printer banner: %s", body)
|
|
|
|
code, body = self.cmd(f"USER {self.username}")
|
|
if code == 331:
|
|
code, body = self.cmd(f"PASS {self.password}", redact=True)
|
|
if code != 230:
|
|
raise PrinterError(f"login rejected ({code}): {body}")
|
|
|
|
for line in ("TYPE I", "PBSZ 0", "PROT P"):
|
|
code, body = self.cmd(line)
|
|
if code != 200:
|
|
raise PrinterError(f"{line!r} rejected ({code}): {body}")
|
|
|
|
LOG.info("logged in to printer %s as %s", self.host, self.username)
|
|
|
|
def cmd(self, line: str, *, redact: bool = False) -> Tuple[int, str]:
|
|
if self._ctrl is None:
|
|
raise PrinterError("control channel is not open")
|
|
LOG.debug("printer < %s", "<redacted>" if redact else line)
|
|
wire = (line + "\r\n").encode("utf-8", errors="replace")
|
|
try:
|
|
self._ctrl.sendall(wire)
|
|
except (OSError, ssl.SSLError) as e:
|
|
raise PrinterError(f"sending {line!r} failed: {e}") from e
|
|
return self._read_reply()
|
|
|
|
def read_reply(self) -> Tuple[int, str]:
|
|
"""Public form of `_read_reply` (used after data transfers)."""
|
|
return self._read_reply()
|
|
|
|
def _read_line(self) -> str:
|
|
while b"\n" not in self._buf:
|
|
try:
|
|
chunk = self._ctrl.recv(4096) if self._ctrl is not None else b""
|
|
except (OSError, ssl.SSLError) as e:
|
|
raise PrinterError(f"recv on control failed: {e}") from e
|
|
if not chunk:
|
|
raise PrinterError("printer closed control connection")
|
|
self._buf += chunk
|
|
nl = self._buf.index(b"\n")
|
|
line = self._buf[:nl]
|
|
self._buf = self._buf[nl + 1:]
|
|
if line.endswith(b"\r"):
|
|
line = line[:-1]
|
|
return line.decode("utf-8", errors="replace")
|
|
|
|
def _read_reply(self) -> Tuple[int, str]:
|
|
first = self._read_line()
|
|
LOG.debug("printer > %s", first)
|
|
if len(first) < 4 or not first[:3].isdigit():
|
|
raise PrinterError(f"bad reply line: {first!r}")
|
|
code = int(first[:3])
|
|
accumulated = [first]
|
|
if first[3] == "-":
|
|
while True:
|
|
line = self._read_line()
|
|
LOG.debug("printer > %s", line)
|
|
accumulated.append(line)
|
|
if (
|
|
len(line) >= 4
|
|
and line[:3].isdigit()
|
|
and int(line[:3]) == code
|
|
and line[3] == " "
|
|
):
|
|
break
|
|
# Strip the leading "NNN " / "NNN-" from each line for the body
|
|
# we pass back; callers don't need to re-parse the code.
|
|
body_lines = []
|
|
for ln in accumulated:
|
|
if len(ln) >= 4 and ln[:3].isdigit() and ln[3] in (" ", "-"):
|
|
body_lines.append(ln[4:])
|
|
else:
|
|
body_lines.append(ln)
|
|
return code, "\n".join(body_lines)
|
|
|
|
# ---- data channel -------------------------------------------------
|
|
|
|
def open_data_socket(self) -> socket.socket:
|
|
"""Sends PASV and returns a *raw* TCP socket connected to the
|
|
printer's data port. The TLS handshake is delayed until after
|
|
the actual data command is acknowledged with 150."""
|
|
code, body = self.cmd("PASV")
|
|
if code != 227:
|
|
raise PrinterError(f"PASV rejected ({code}): {body}")
|
|
m = PASV_RE.search(body)
|
|
if not m:
|
|
raise PrinterError(f"PASV body unparseable: {body!r}")
|
|
p1, p2 = int(m.group(5)), int(m.group(6))
|
|
port = p1 * 256 + p2
|
|
# Discard the printer-supplied IP (often 0.0.0.0 / private) and
|
|
# reconnect to the control host - same trick as
|
|
# `open_data_tcp` in src/ftps.cpp.
|
|
try:
|
|
sock = socket.create_connection((self.host, port), timeout=self.data_timeout)
|
|
except OSError as e:
|
|
raise PrinterError(
|
|
f"data tcp connect to {self.host}:{port} failed: {e}"
|
|
) from e
|
|
sock.settimeout(self.data_timeout)
|
|
return sock
|
|
|
|
def wrap_data_tls(self, sock: socket.socket) -> ssl.SSLSocket:
|
|
"""Wraps a connected data socket with TLS, reusing the control
|
|
session where supported."""
|
|
ssock = self._ctx.wrap_socket(
|
|
sock,
|
|
server_hostname=self.host,
|
|
do_handshake_on_connect=False,
|
|
)
|
|
# Reuse the control channel session. Some FTPS servers
|
|
# (pureftpd hardened, vsftpd with require_ssl_reuse=YES) refuse
|
|
# otherwise. Bambu firmware doesn't enforce this today but
|
|
# mirroring the C++ code keeps us portable.
|
|
try:
|
|
if self._ctrl is not None:
|
|
ctrl_session = self._ctrl.session
|
|
if ctrl_session is not None:
|
|
ssock.session = ctrl_session
|
|
except (ssl.SSLError, AttributeError, OSError) as e:
|
|
LOG.debug("data session reuse skipped: %s", e)
|
|
try:
|
|
ssock.do_handshake()
|
|
except (ssl.SSLError, OSError) as e:
|
|
try:
|
|
ssock.close()
|
|
except OSError:
|
|
pass
|
|
raise PrinterError(f"data TLS handshake failed: {e}") from e
|
|
return ssock
|
|
|
|
def close(self) -> None:
|
|
if self._ctrl is None:
|
|
return
|
|
try:
|
|
self._ctrl.sendall(b"QUIT\r\n")
|
|
try:
|
|
self._ctrl.settimeout(1.0)
|
|
self._read_reply()
|
|
except (PrinterError, OSError, ssl.SSLError):
|
|
pass
|
|
except (OSError, ssl.SSLError):
|
|
pass
|
|
try:
|
|
self._ctrl.unwrap()
|
|
except (OSError, ssl.SSLError):
|
|
pass
|
|
try:
|
|
self._ctrl.close()
|
|
except OSError:
|
|
pass
|
|
self._ctrl = None
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Plaintext FTP server side: per-client session
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
def parse_ls_name(line: str) -> Optional[str]:
|
|
"""Extract the filename from an `ls -l` style listing line.
|
|
|
|
Mirrors the trailing-name logic in `src/ftps_parse.hpp::parse_ls_line`
|
|
so NLST output matches what LIST advertises."""
|
|
if not line:
|
|
return None
|
|
parts = line.split(None, 8)
|
|
if len(parts) < 9:
|
|
return None
|
|
name = parts[8]
|
|
arrow = name.find(" -> ")
|
|
if arrow != -1:
|
|
name = name[:arrow]
|
|
return name
|
|
|
|
|
|
class ProxySession:
|
|
"""One client thread. Translates plaintext FTP -> printer FTPS."""
|
|
|
|
HANDLERS: Dict[str, Callable[["ProxySession", str], bool]] = {}
|
|
|
|
def __init__(
|
|
self,
|
|
client_sock: socket.socket,
|
|
peer_addr: Tuple[str, int],
|
|
printer_host: str,
|
|
printer_port: int,
|
|
cli_username: str,
|
|
cli_password: Optional[str],
|
|
listen_host: str,
|
|
control_timeout: float,
|
|
data_timeout: float,
|
|
) -> None:
|
|
self.client = client_sock
|
|
self.peer_addr = peer_addr
|
|
self.printer_host = printer_host
|
|
self.printer_port = printer_port
|
|
# When `cli_password` is not None, every client is logged in to
|
|
# the printer with these creds regardless of what they sent.
|
|
# Otherwise the client's USER/PASS are forwarded verbatim.
|
|
self.cli_username = cli_username
|
|
self.cli_password = cli_password
|
|
self.listen_host = listen_host
|
|
self.control_timeout = control_timeout
|
|
self.data_timeout = data_timeout
|
|
|
|
self.client.settimeout(300.0)
|
|
self._buf = b""
|
|
self.printer: Optional[PrinterFtps] = None
|
|
self._pasv_listener: Optional[socket.socket] = None
|
|
self._authed = False
|
|
self._pending_user: Optional[str] = None
|
|
|
|
# ---- I/O helpers --------------------------------------------------
|
|
|
|
def reply(self, code: int, body: str = "") -> None:
|
|
if not body:
|
|
wire = f"{code}\r\n".encode("utf-8")
|
|
else:
|
|
lines = body.split("\n")
|
|
if len(lines) == 1:
|
|
wire = f"{code} {lines[0]}\r\n".encode("utf-8", errors="replace")
|
|
else:
|
|
# Multiline reply: "NNN-first\r\n second\r\n NNN last\r\n"
|
|
pieces = []
|
|
for i, ln in enumerate(lines):
|
|
if i == 0:
|
|
pieces.append(f"{code}-{ln}")
|
|
elif i == len(lines) - 1:
|
|
pieces.append(f"{code} {ln}")
|
|
else:
|
|
pieces.append(f" {ln}")
|
|
wire = ("\r\n".join(pieces) + "\r\n").encode("utf-8", errors="replace")
|
|
try:
|
|
self.client.sendall(wire)
|
|
except OSError as e:
|
|
LOG.debug("[%s] send to client failed: %s", self.peer_addr, e)
|
|
raise
|
|
LOG.debug(
|
|
"[%s] -> %s", self.peer_addr, wire.rstrip(b"\r\n").decode(errors="replace")
|
|
)
|
|
|
|
def _read_command(self) -> Optional[str]:
|
|
while b"\n" not in self._buf:
|
|
try:
|
|
chunk = self.client.recv(4096)
|
|
except (OSError, socket.timeout) as e:
|
|
LOG.debug("[%s] client recv: %s", self.peer_addr, e)
|
|
return None
|
|
if not chunk:
|
|
return None
|
|
self._buf += chunk
|
|
if len(self._buf) > 8192:
|
|
LOG.warning("[%s] command line too long, dropping", self.peer_addr)
|
|
return None
|
|
nl = self._buf.index(b"\n")
|
|
line = self._buf[:nl]
|
|
self._buf = self._buf[nl + 1:]
|
|
if line.endswith(b"\r"):
|
|
line = line[:-1]
|
|
return line.decode("utf-8", errors="replace")
|
|
|
|
# ---- main loop ----------------------------------------------------
|
|
|
|
def run(self) -> None:
|
|
try:
|
|
self._run()
|
|
except Exception:
|
|
LOG.exception("[%s] session crashed", self.peer_addr)
|
|
finally:
|
|
self._cleanup()
|
|
|
|
def _connect_printer(self, username: str, password: str) -> None:
|
|
if self.printer is not None:
|
|
return
|
|
p = PrinterFtps(
|
|
host=self.printer_host,
|
|
port=self.printer_port,
|
|
username=username,
|
|
password=password,
|
|
control_timeout=self.control_timeout,
|
|
data_timeout=self.data_timeout,
|
|
)
|
|
p.connect()
|
|
self.printer = p
|
|
|
|
def _run(self) -> None:
|
|
try:
|
|
self.reply(220, f"bambu-ftp-proxy ready (printer {self.printer_host})")
|
|
except OSError:
|
|
return
|
|
|
|
while True:
|
|
line = self._read_command()
|
|
if line is None:
|
|
LOG.info("[%s] client disconnected", self.peer_addr)
|
|
return
|
|
if not line.strip():
|
|
continue
|
|
parts = line.strip().split(" ", 1)
|
|
cmd = parts[0].upper()
|
|
arg = parts[1] if len(parts) > 1 else ""
|
|
|
|
redact = cmd == "PASS"
|
|
LOG.debug(
|
|
"[%s] <- %s %s",
|
|
self.peer_addr,
|
|
cmd,
|
|
"<redacted>" if redact else arg,
|
|
)
|
|
|
|
if not self._authed and cmd not in PRELOGIN_COMMANDS:
|
|
try:
|
|
self.reply(530, "Please log in with USER and PASS first")
|
|
except OSError:
|
|
return
|
|
continue
|
|
|
|
handler = self.HANDLERS.get(cmd)
|
|
if handler is None:
|
|
try:
|
|
self.reply(502, f"Command not implemented: {cmd}")
|
|
except OSError:
|
|
return
|
|
continue
|
|
|
|
try:
|
|
cont = handler(self, arg)
|
|
except PrinterError as e:
|
|
LOG.warning("[%s] printer error: %s", self.peer_addr, e)
|
|
try:
|
|
self.reply(421, f"Printer session lost: {e}")
|
|
except OSError:
|
|
pass
|
|
return
|
|
except OSError as e:
|
|
LOG.warning("[%s] socket error: %s", self.peer_addr, e)
|
|
return
|
|
|
|
if not cont:
|
|
return
|
|
|
|
# ---- command handlers --------------------------------------------
|
|
|
|
def _cmd_user(self, arg: str) -> bool:
|
|
self._pending_user = arg if arg else None
|
|
if self.cli_password is not None:
|
|
self.reply(331, "Any password welcome (proxy uses preset creds)")
|
|
else:
|
|
self.reply(331, "Send the printer access code as PASS")
|
|
return True
|
|
|
|
def _cmd_pass(self, arg: str) -> bool:
|
|
if self.cli_password is not None:
|
|
user = self.cli_username
|
|
pw = self.cli_password
|
|
else:
|
|
user = self._pending_user or self.cli_username
|
|
pw = arg
|
|
if not pw:
|
|
self.reply(530, "Password required (printer access code)")
|
|
return True
|
|
try:
|
|
self._connect_printer(user, pw)
|
|
except PrinterError as e:
|
|
LOG.warning("[%s] printer login failed: %s", self.peer_addr, e)
|
|
# Allow the client to retry with different creds. Connection
|
|
# to the printer is closed on PrinterFtps.connect() failure
|
|
# before self.printer is set, so the next PASS starts fresh.
|
|
self.reply(530, f"Printer login failed: {e}")
|
|
return True
|
|
self._authed = True
|
|
self.reply(230, "Logged in via proxy")
|
|
return True
|
|
|
|
def _cmd_acct(self, arg: str) -> bool:
|
|
self.reply(202, "ACCT not needed")
|
|
return True
|
|
|
|
def _cmd_syst(self, arg: str) -> bool:
|
|
self.reply(215, "UNIX Type: L8")
|
|
return True
|
|
|
|
def _cmd_feat(self, arg: str) -> bool:
|
|
# Deliberately omit MLSD/MDTM/REST -- Bambu firmware does not
|
|
# implement them. Listing them would only trick clients into
|
|
# issuing commands that bounce back as 500.
|
|
self.reply(211, "Features:\n PASV\n EPSV\n UTF8\n SIZE\n TYPE\nEnd")
|
|
return True
|
|
|
|
def _cmd_opts(self, arg: str) -> bool:
|
|
if arg.strip().upper().startswith("UTF8"):
|
|
self.reply(200, "UTF8 OK")
|
|
else:
|
|
self.reply(501, "Unknown option")
|
|
return True
|
|
|
|
def _cmd_type(self, arg: str) -> bool:
|
|
a = arg.strip().upper()
|
|
if a in ("I", "L 8", "A", "A N"):
|
|
if self.printer is not None:
|
|
code, body = self.printer.cmd(f"TYPE {a}")
|
|
self.reply(code, body)
|
|
else:
|
|
self.reply(200, f"Type set to {a}")
|
|
else:
|
|
self.reply(504, f"Type not supported: {a}")
|
|
return True
|
|
|
|
def _cmd_pwd(self, arg: str) -> bool:
|
|
if self.printer is None:
|
|
self.reply(257, '"/" is current directory')
|
|
return True
|
|
code, body = self.printer.cmd("PWD")
|
|
if code == 257:
|
|
self.reply(257, body)
|
|
else:
|
|
# Printer doesn't expose PWD reliably; fake a response.
|
|
self.reply(257, '"/" is current directory')
|
|
return True
|
|
|
|
def _cmd_cwd(self, arg: str) -> bool:
|
|
path = arg if arg else "/"
|
|
if self.printer is None:
|
|
self.reply(503, "Login first")
|
|
return True
|
|
code, body = self.printer.cmd(f"CWD {path}")
|
|
self.reply(code, body)
|
|
return True
|
|
|
|
def _cmd_cdup(self, arg: str) -> bool:
|
|
if self.printer is None:
|
|
self.reply(503, "Login first")
|
|
return True
|
|
code, body = self.printer.cmd("CDUP")
|
|
self.reply(code, body)
|
|
return True
|
|
|
|
def _cmd_noop(self, arg: str) -> bool:
|
|
if self.printer is not None:
|
|
code, body = self.printer.cmd("NOOP")
|
|
self.reply(code, body)
|
|
else:
|
|
self.reply(200, "OK")
|
|
return True
|
|
|
|
def _cmd_size(self, arg: str) -> bool:
|
|
if not arg:
|
|
self.reply(501, "Missing path")
|
|
return True
|
|
if self.printer is None:
|
|
self.reply(503, "Login first")
|
|
return True
|
|
code, body = self.printer.cmd(f"SIZE {arg}")
|
|
self.reply(code, body)
|
|
return True
|
|
|
|
def _cmd_dele(self, arg: str) -> bool:
|
|
if not arg:
|
|
self.reply(501, "Missing path")
|
|
return True
|
|
if self.printer is None:
|
|
self.reply(503, "Login first")
|
|
return True
|
|
code, body = self.printer.cmd(f"DELE {arg}")
|
|
self.reply(code, body)
|
|
return True
|
|
|
|
def _cmd_quit(self, arg: str) -> bool:
|
|
try:
|
|
self.reply(221, "Goodbye")
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
def _cmd_pasv(self, arg: str) -> bool:
|
|
self._close_pasv_listener()
|
|
try:
|
|
listener = self._open_pasv_listener()
|
|
except OSError as e:
|
|
self.reply(425, f"Can't open passive: {e}")
|
|
return True
|
|
|
|
port = listener.getsockname()[1]
|
|
local_ip = self._advertised_local_ip()
|
|
if local_ip is None:
|
|
listener.close()
|
|
self.reply(425, "PASV needs an IPv4 listen address; use EPSV")
|
|
return True
|
|
h = local_ip.split(".")
|
|
p1, p2 = port // 256, port % 256
|
|
self._pasv_listener = listener
|
|
self.reply(
|
|
227,
|
|
f"Entering Passive Mode ({h[0]},{h[1]},{h[2]},{h[3]},{p1},{p2}).",
|
|
)
|
|
return True
|
|
|
|
def _cmd_epsv(self, arg: str) -> bool:
|
|
a = arg.strip().upper()
|
|
if a == "ALL":
|
|
self.reply(200, "Only EPSV from now on")
|
|
return True
|
|
# Numeric protocol arg ("1"=IPv4, "2"=IPv6) is accepted but
|
|
# ignored - we always advertise on the same family the listener
|
|
# is bound to.
|
|
self._close_pasv_listener()
|
|
try:
|
|
listener = self._open_pasv_listener()
|
|
except OSError as e:
|
|
self.reply(425, f"Can't open passive: {e}")
|
|
return True
|
|
port = listener.getsockname()[1]
|
|
self._pasv_listener = listener
|
|
self.reply(229, f"Entering Extended Passive Mode (|||{port}|).")
|
|
return True
|
|
|
|
def _data_cmd(self, cmd: str, arg: str) -> bool:
|
|
if self._pasv_listener is None:
|
|
self.reply(425, "Use PASV/EPSV first")
|
|
return True
|
|
listener = self._pasv_listener
|
|
# PASV listener is one-shot. If anything below fails we still
|
|
# need to release this socket.
|
|
self._pasv_listener = None
|
|
try:
|
|
return self._do_data_transfer(cmd, arg, listener)
|
|
finally:
|
|
try:
|
|
listener.close()
|
|
except OSError:
|
|
pass
|
|
|
|
# ---- PASV plumbing -----------------------------------------------
|
|
|
|
def _open_pasv_listener(self) -> socket.socket:
|
|
bind_host = self.listen_host or "0.0.0.0"
|
|
# Pick AF based on the bind address.
|
|
if ":" in bind_host:
|
|
family = socket.AF_INET6
|
|
else:
|
|
family = socket.AF_INET
|
|
listener = socket.socket(family, socket.SOCK_STREAM)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
listener.bind((bind_host, 0))
|
|
listener.listen(1)
|
|
return listener
|
|
|
|
def _advertised_local_ip(self) -> Optional[str]:
|
|
if self.listen_host and self.listen_host not in ("0.0.0.0", "::", ""):
|
|
ip = self.listen_host
|
|
else:
|
|
try:
|
|
ip = self.client.getsockname()[0]
|
|
except OSError:
|
|
ip = "127.0.0.1"
|
|
if ":" in ip:
|
|
return None
|
|
if ip.count(".") != 3:
|
|
return None
|
|
return ip
|
|
|
|
def _close_pasv_listener(self) -> None:
|
|
if self._pasv_listener is not None:
|
|
try:
|
|
self._pasv_listener.close()
|
|
except OSError:
|
|
pass
|
|
self._pasv_listener = None
|
|
|
|
# ---- data transfer ----------------------------------------------
|
|
|
|
def _do_data_transfer(
|
|
self, cmd: str, arg: str, listener: socket.socket
|
|
) -> bool:
|
|
if self.printer is None:
|
|
self.reply(503, "Login first")
|
|
return True
|
|
|
|
try:
|
|
printer_data_raw = self.printer.open_data_socket()
|
|
except PrinterError as e:
|
|
self.reply(425, f"Printer PASV failed: {e}")
|
|
return True
|
|
|
|
# Bambu firmware doesn't support NLST cleanly across all builds;
|
|
# use LIST and parse names. parse_ls_line / parse_ls_name handle
|
|
# both date forms.
|
|
printer_cmd = "LIST" if cmd == "NLST" else cmd
|
|
wire = f"{printer_cmd} {arg}".strip() if arg else printer_cmd
|
|
try:
|
|
code, body = self.printer.cmd(wire)
|
|
except PrinterError:
|
|
try:
|
|
printer_data_raw.close()
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
if code not in (150, 125):
|
|
try:
|
|
printer_data_raw.close()
|
|
except OSError:
|
|
pass
|
|
self.reply(code, body)
|
|
return True
|
|
|
|
# Forward the printer's 150 to the client. Many clients use this
|
|
# as their cue to start streaming for STOR.
|
|
self.reply(code, body if body else "Opening data connection")
|
|
|
|
# Accept the client's data socket. Most clients will have
|
|
# already connected by now; the accept either succeeds
|
|
# immediately or waits briefly.
|
|
listener.settimeout(60.0)
|
|
try:
|
|
client_data, _ = listener.accept()
|
|
except (socket.timeout, OSError) as e:
|
|
try:
|
|
printer_data_raw.close()
|
|
except OSError:
|
|
pass
|
|
self.reply(425, f"Client data accept failed: {e}")
|
|
try:
|
|
self.printer.cmd("ABOR")
|
|
except PrinterError:
|
|
raise
|
|
return True
|
|
client_data.settimeout(self.data_timeout)
|
|
|
|
try:
|
|
printer_data = self.printer.wrap_data_tls(printer_data_raw)
|
|
except PrinterError as e:
|
|
try:
|
|
client_data.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
printer_data_raw.close()
|
|
except OSError:
|
|
pass
|
|
self.reply(425, f"Data TLS handshake failed: {e}")
|
|
return True
|
|
|
|
try:
|
|
if cmd == "STOR":
|
|
self._pump_client_to_printer(client_data, printer_data)
|
|
elif cmd == "NLST":
|
|
self._pump_printer_to_client_nlst(client_data, printer_data)
|
|
else: # LIST, RETR
|
|
self._pump_printer_to_client(client_data, printer_data)
|
|
except OSError as e:
|
|
LOG.warning("[%s] data transfer error: %s", self.peer_addr, e)
|
|
finally:
|
|
self._close_data_pair(client_data, printer_data)
|
|
|
|
try:
|
|
done_code, done_body = self.printer.read_reply()
|
|
except PrinterError:
|
|
raise
|
|
self.reply(done_code, done_body if done_body else "Transfer complete")
|
|
return True
|
|
|
|
@staticmethod
|
|
def _close_data_pair(client_data: socket.socket, printer_data: ssl.SSLSocket) -> None:
|
|
# Send TLS close_notify so vsftpd cleanly finalises the
|
|
# transfer; mirrors the SSL_shutdown call in src/ftps.cpp.
|
|
try:
|
|
printer_data.unwrap()
|
|
except (OSError, ssl.SSLError):
|
|
pass
|
|
try:
|
|
printer_data.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
client_data.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
client_data.close()
|
|
except OSError:
|
|
pass
|
|
|
|
# ---- byte pumps ---------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _pump_printer_to_client(
|
|
client: socket.socket, printer: ssl.SSLSocket
|
|
) -> None:
|
|
while True:
|
|
try:
|
|
data = printer.recv(64 * 1024)
|
|
except ssl.SSLError as e:
|
|
# OpenSSL 3 raises SSLEOFError for the common "vsftpd
|
|
# closed without close_notify" case; treat as clean EOF.
|
|
if "UNEXPECTED_EOF_WHILE_READING" in str(e) or isinstance(
|
|
e, getattr(ssl, "SSLEOFError", ())
|
|
):
|
|
return
|
|
raise
|
|
if not data:
|
|
return
|
|
try:
|
|
client.sendall(data)
|
|
except OSError:
|
|
return
|
|
|
|
@staticmethod
|
|
def _pump_client_to_printer(
|
|
client: socket.socket, printer: ssl.SSLSocket
|
|
) -> None:
|
|
while True:
|
|
try:
|
|
data = client.recv(64 * 1024)
|
|
except OSError:
|
|
return
|
|
if not data:
|
|
return
|
|
try:
|
|
printer.sendall(data)
|
|
except (OSError, ssl.SSLError):
|
|
return
|
|
|
|
@staticmethod
|
|
def _pump_printer_to_client_nlst(
|
|
client: socket.socket, printer: ssl.SSLSocket
|
|
) -> None:
|
|
buf = b""
|
|
while True:
|
|
try:
|
|
data = printer.recv(64 * 1024)
|
|
except ssl.SSLError as e:
|
|
if "UNEXPECTED_EOF_WHILE_READING" in str(e) or isinstance(
|
|
e, getattr(ssl, "SSLEOFError", ())
|
|
):
|
|
break
|
|
raise
|
|
if not data:
|
|
break
|
|
buf += data
|
|
text = buf.decode("utf-8", errors="replace")
|
|
names = []
|
|
for line in text.splitlines():
|
|
name = parse_ls_name(line)
|
|
if name and name not in (".", ".."):
|
|
names.append(name)
|
|
if names:
|
|
payload = ("\r\n".join(names) + "\r\n").encode("utf-8", errors="replace")
|
|
try:
|
|
client.sendall(payload)
|
|
except OSError:
|
|
pass
|
|
|
|
# ---- shutdown -----------------------------------------------------
|
|
|
|
def _cleanup(self) -> None:
|
|
self._close_pasv_listener()
|
|
if self.printer is not None:
|
|
try:
|
|
self.printer.close()
|
|
except Exception:
|
|
pass
|
|
self.printer = None
|
|
try:
|
|
self.client.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# Wire up the dispatch table now that all methods exist.
|
|
ProxySession.HANDLERS.update(
|
|
{
|
|
"USER": ProxySession._cmd_user,
|
|
"PASS": ProxySession._cmd_pass,
|
|
"ACCT": ProxySession._cmd_acct,
|
|
"SYST": ProxySession._cmd_syst,
|
|
"FEAT": ProxySession._cmd_feat,
|
|
"OPTS": ProxySession._cmd_opts,
|
|
"TYPE": ProxySession._cmd_type,
|
|
"PWD": ProxySession._cmd_pwd,
|
|
"XPWD": ProxySession._cmd_pwd,
|
|
"CWD": ProxySession._cmd_cwd,
|
|
"XCWD": ProxySession._cmd_cwd,
|
|
"CDUP": ProxySession._cmd_cdup,
|
|
"XCUP": ProxySession._cmd_cdup,
|
|
"NOOP": ProxySession._cmd_noop,
|
|
"SIZE": ProxySession._cmd_size,
|
|
"DELE": ProxySession._cmd_dele,
|
|
"QUIT": ProxySession._cmd_quit,
|
|
"PASV": ProxySession._cmd_pasv,
|
|
"EPSV": ProxySession._cmd_epsv,
|
|
"LIST": lambda self, arg: self._data_cmd("LIST", arg),
|
|
"NLST": lambda self, arg: self._data_cmd("NLST", arg),
|
|
"RETR": lambda self, arg: self._data_cmd("RETR", arg),
|
|
"STOR": lambda self, arg: self._data_cmd("STOR", arg),
|
|
}
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# main
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
def parse_args(argv=None) -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(
|
|
prog="bambu_ftp_proxy",
|
|
description=(
|
|
"Plaintext FTP server that proxies to a Bambu printer's "
|
|
"implicit-FTPS daemon (TCP/990)."
|
|
),
|
|
)
|
|
p.add_argument("printer_ip", help="Printer LAN IP (e.g. 10.13.1.30)")
|
|
p.add_argument(
|
|
"access_code",
|
|
nargs="?",
|
|
default=None,
|
|
help=(
|
|
"Printer access code shown on the printer screen. If "
|
|
"omitted, the proxy runs in pass-through mode: the FTP "
|
|
"client must send the access code as the PASS password."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--listen-host",
|
|
default="127.0.0.1",
|
|
help="Address to bind the plaintext FTP server (default: 127.0.0.1)",
|
|
)
|
|
p.add_argument(
|
|
"--listen-port",
|
|
type=int,
|
|
default=2121,
|
|
help="Port for the plaintext FTP server (default: 2121)",
|
|
)
|
|
p.add_argument(
|
|
"--printer-port",
|
|
type=int,
|
|
default=990,
|
|
help="Printer FTPS port (default: 990)",
|
|
)
|
|
p.add_argument(
|
|
"--username",
|
|
default="bblp",
|
|
help="Username for the printer (default: bblp)",
|
|
)
|
|
p.add_argument(
|
|
"--control-timeout",
|
|
type=float,
|
|
default=15.0,
|
|
help="Per-syscall timeout on the printer control channel, seconds",
|
|
)
|
|
p.add_argument(
|
|
"--data-timeout",
|
|
type=float,
|
|
default=120.0,
|
|
help="Per-syscall timeout on data transfers, seconds",
|
|
)
|
|
p.add_argument(
|
|
"-v",
|
|
"--verbose",
|
|
action="count",
|
|
default=0,
|
|
help="Increase log verbosity (-v: INFO, -vv: DEBUG)",
|
|
)
|
|
return p.parse_args(argv)
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
args = parse_args(argv)
|
|
level = logging.WARNING
|
|
if args.verbose >= 2:
|
|
level = logging.DEBUG
|
|
elif args.verbose == 1:
|
|
level = logging.INFO
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
|
|
family = socket.AF_INET6 if ":" in args.listen_host else socket.AF_INET
|
|
server = socket.socket(family, socket.SOCK_STREAM)
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
server.bind((args.listen_host, args.listen_port))
|
|
except OSError as e:
|
|
print(
|
|
f"bind {args.listen_host}:{args.listen_port}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
server.listen(8)
|
|
|
|
mode = "preset access code" if args.access_code else "pass-through PASS"
|
|
print(
|
|
f"Listening on ftp://{args.listen_host}:{args.listen_port}/ "
|
|
f"-> printer {args.printer_ip}:{args.printer_port} ({mode})",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
client_sock, peer = server.accept()
|
|
except KeyboardInterrupt:
|
|
break
|
|
except OSError as e:
|
|
LOG.warning("accept failed: %s", e)
|
|
continue
|
|
LOG.info("client connected: %s", peer)
|
|
sess = ProxySession(
|
|
client_sock=client_sock,
|
|
peer_addr=peer,
|
|
printer_host=args.printer_ip,
|
|
printer_port=args.printer_port,
|
|
cli_username=args.username,
|
|
cli_password=args.access_code,
|
|
listen_host=args.listen_host,
|
|
control_timeout=args.control_timeout,
|
|
data_timeout=args.data_timeout,
|
|
)
|
|
t = threading.Thread(
|
|
target=sess.run,
|
|
name=f"ftp-{peer[0]}:{peer[1]}",
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
finally:
|
|
try:
|
|
server.close()
|
|
except OSError:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|