mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
@grolmus measured a 9-printer farm and the numbers settle what this
failure is not. Reproduced here, three results:
cleartext "421" banner on the TLS port
-> [SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)
1.2-only server, client forced to 1.3
-> [SSL: TLSV1_ALERT_PROTOCOL_VERSION]
1.2-only server, an uncapped client
-> negotiates 1.2 and connects
The first is byte-for-byte what the farm logs. So WRONG_VERSION_NUMBER
means the printer's first bytes were not a TLS record, a version
mismatch cannot produce it, and reaching a 1.2-only peer needs no cap.
What it still does not say is WHICH cleartext message, and that is the
part that would name the fault. OpenSSL has eaten those bytes by the
time the exception surfaces, so on this error the client now opens one
plain connection and reads them. The log then carries the printer's own
words -- an FTP refusal such as "421 Too many connections" would settle
it outright -- marked as the line to quote in a report. This gets the
answer from every affected install rather than from the one farm able
to take a packet capture.
Three things keep it from making the suspected fault worse:
- The failed socket is closed BEFORE the probe opens its connection.
Holding a dead handshake open across a second connect to a printer
that may be out of connection slots is the leak #2780's own cleanup
was added to stop.
- It asks once per cool-off window, not once per attempt. Checked
before the new deadline is written, so a live entry means an earlier
failure already asked -- which matters because a dispatch ignores the
cool-off (#2898) and reaches this branch four times.
- Connect and read share one timeout budget rather than getting one
each.
Only WRONG_VERSION_NUMBER is probed. A protocol-version alert means the
peer did speak TLS, so there is nothing in the clear to read and the
probe would only sit out its timeout. A vsFTPd answering its connection
limit by accepting and staying silent -- the other half of the standing
theory -- arrives as a handshake timeout and lands on that branch
instead; there is a test saying so, because widening the trigger later
would look like an improvement.
The profile registry is corrected to what was measured. Its docstring
claimed "the P2S evidently does offer 1.3"; six P2S units refuse it.
Worse, the X2D (#1638) and H2C (#2582) entries were capped on the
reading that WRONG_VERSION_NUMBER came from a TLS-1.3 ClientHello,
which cannot happen -- so the cap is not what changed those outcomes
and both are now marked RE-TEST WANTED. They are kept rather than
removed: their reporters saw the symptom clear, nobody here has that
hardware, and the entry costs nothing on a printer that does not offer
1.3 anyway. The P2S entry (#1401) is a different symptom -- a 426
truncation mid-transfer -- and is the only one a session-ticket problem
could explain, though grolmus's firmware refuses 1.3 there too.
Both measurements are pinned by tests, so the explanation stays
falsifiable instead of becoming the next set of confident wrong
comments. Two existing cool-off tests now count two connections where
they counted one; the promise they exist for -- contacted twice, not
~110 -- is unchanged, and they say why rather than carrying a new
number.
165 lines
7.5 KiB
Python
165 lines
7.5 KiB
Python
"""Per-printer-model FTP tuning knobs.
|
||
|
||
Mirrors the shape of :mod:`backend.app.services.camera_profiles` — a
|
||
small registry of per-model overrides so quirky firmwares can be
|
||
tuned without sprinkling ``if model == "X":`` branches through
|
||
``bambu_ftp.py``. Adding a new model's quirk is a config edit (an
|
||
entry in ``_PROFILES`` plus the alias for its internal SSDP code if
|
||
needed), not another hard-coded branch.
|
||
|
||
The default profile matches the historical pre-fix behaviour, so
|
||
every model that doesn't have an entry here keeps its existing FTP
|
||
behaviour byte-for-byte.
|
||
|
||
Currently only the TLS-version cap lives here (P2S firmware
|
||
01.02.00.00 needs it — see ``cap_tls_v1_2`` below). The A1
|
||
data-channel-plaintext quirk still lives in :class:`BambuFTPClient`
|
||
via ``A1_MODELS`` / ``skip_session_reuse``; folding that into a
|
||
profile field is a future cleanup, not load-bearing for this fix.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FTPProfile:
|
||
"""Tuning knobs for one printer model's FTP path.
|
||
|
||
All defaults reflect the historical behaviour. Models with quirky
|
||
firmware override individual fields rather than re-defining the
|
||
whole profile.
|
||
"""
|
||
|
||
# Pin the SSL context's ``maximum_version`` to TLS 1.2.
|
||
#
|
||
# ``ssl.create_default_context()`` negotiates TLS 1.3 when both peers
|
||
# support it. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
|
||
# by @iitazz, #1401) implement session reuse on the FTPS data
|
||
# channel against an old vsFTPd build that doesn't tolerate TLS
|
||
# 1.3's asynchronous session-ticket model: the data channel gets
|
||
# torn down mid-stream and the upload aborts with 426 "Failure
|
||
# reading network stream" — visible as a clean truncation at a
|
||
# chunk boundary (one reporter saw exactly 7 × 64 KB landed on
|
||
# the printer). Capping to TLS 1.2 makes session resumption
|
||
# synchronous and the upload completes normally.
|
||
#
|
||
# This cap only bites on models that *offer* 1.3 in the first place,
|
||
# and on the evidence so far none of them do. Probed directly on
|
||
# :990, an X1C and an H2D refuse TLS 1.0, 1.1 and 1.3 and complete
|
||
# only on 1.2; @grolmus then probed a 9-printer farm (#2780,
|
||
# 2026-08-21) and got the same result on six P2S units, two X1C and
|
||
# an H2D — tls1_3 refused, tls1_2 ok, every one. This comment used
|
||
# to claim "the P2S evidently does offer 1.3"; six say otherwise.
|
||
#
|
||
# A cap is also not needed to reach a 1.2-only peer. Measured
|
||
# against a local TLS-1.2-only server with the same context this
|
||
# module builds: an uncapped client negotiates 1.2 and connects.
|
||
# A client forced to 1.3 gets TLSV1_ALERT_PROTOCOL_VERSION — never
|
||
# WRONG_VERSION_NUMBER, which comes from bytes that are not a TLS
|
||
# record at all. See
|
||
# ``tests/unit/services/test_cleartext_probe_2780.py``, which pins
|
||
# both measurements so this comment stays falsifiable.
|
||
#
|
||
# So the entries below are kept as tuning slots and as a record of
|
||
# what each reporter saw, not because the mechanism is understood.
|
||
# Two of the three explain a symptom this cap cannot affect; see
|
||
# their own comments.
|
||
# (P1S untested; no claim made either way.)
|
||
#
|
||
# **Defaults to False** — only applied to printer models where a
|
||
# reporter has confirmed the symptom. This is deliberately
|
||
# conservative; flipping a printer to the capped path is a config
|
||
# edit when a new model surfaces the same bug.
|
||
cap_tls_v1_2: bool = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Profile registry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Default profile = historical behaviour. Used for every model that
|
||
# doesn't have an entry in ``_PROFILES``.
|
||
DEFAULT_PROFILE = FTPProfile()
|
||
|
||
# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
|
||
# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
|
||
# ``_MODEL_ALIASES`` below.
|
||
_PROFILES: dict[str, FTPProfile] = {
|
||
# P2S firmware 01.02.00.00 (#1401, reporter @iitazz). Symptom is a
|
||
# 426 truncation part-way through a transfer, on the data channel —
|
||
# a different failure from the handshake ones below, and the only
|
||
# one here whose mechanism a TLS-1.3 session-ticket problem could
|
||
# actually explain. The reporter confirmed the fix.
|
||
#
|
||
# Unresolved: @grolmus's six P2S units refuse TLS 1.3 outright
|
||
# (#2780), so on their firmware the negotiated version was already
|
||
# 1.2 and this cap changes nothing. Either the firmware moved
|
||
# between the two reports, or #1401 was fixed by something else in
|
||
# the same change. Kept because a reporter confirmed it and no one
|
||
# has hardware to re-test it on.
|
||
"P2S": FTPProfile(
|
||
cap_tls_v1_2=True,
|
||
),
|
||
# X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
|
||
# port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` (#1638, reporter
|
||
# @vasmarfas). Without the 3MF download the print falls through to
|
||
# the no-3MF fallback archive path and the card lands almost empty
|
||
# (no filament total, no layers, no MakerWorld link).
|
||
#
|
||
# RE-TEST WANTED. This was capped on the reading that the error came
|
||
# from "Python 3.13's default TLS-1.3 ClientHello". That reading is
|
||
# now measured wrong: WRONG_VERSION_NUMBER is what a *non-TLS*
|
||
# answer produces, a version mismatch reports itself differently,
|
||
# and an uncapped client reaches a 1.2-only peer unaided (#2780).
|
||
# So this cap cannot be what changed the outcome, and the X2D is
|
||
# most likely answering :990 with something that is not TLS — the
|
||
# cleartext probe in ``bambu_ftp`` will now say what. Left in place
|
||
# rather than removed: nobody here has an X2D, and the entry costs
|
||
# nothing on a printer that does not offer 1.3 anyway.
|
||
"X2D": FTPProfile(
|
||
cap_tls_v1_2=True,
|
||
),
|
||
# H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083). The sliced
|
||
# 3MF intermittently fails to come off the printer over FTPS, so the
|
||
# print drops to the no-3MF fallback archive with no slice data —
|
||
# which is why the Print Log shows no filament and nothing is
|
||
# deducted.
|
||
#
|
||
# RE-TEST WANTED, same reasoning as the X2D above. Capped "by
|
||
# analogy with P2S" on the belief that the profile-less path "ran on
|
||
# the Python-default TLS 1.3"; measurement says a 1.2-only peer
|
||
# negotiates 1.2 without a cap, so there was no 1.3 to fall back
|
||
# from (#2780). "Intermittent" now points somewhere better: it is
|
||
# the signature of the transient non-TLS refusal @grolmus sees on
|
||
# his P2S units, which is the same H2 firmware line.
|
||
"H2C": FTPProfile(
|
||
cap_tls_v1_2=True,
|
||
),
|
||
}
|
||
|
||
# SSDP internal codes that should resolve to a display-name profile.
|
||
# Mirrors the same map in :mod:`camera_profiles`.
|
||
_MODEL_ALIASES: dict[str, str] = {
|
||
"N7": "P2S", # P2S internal SSDP code
|
||
"N6": "X2D", # X2D internal SSDP code
|
||
"O1C": "H2C", # H2C internal SSDP code
|
||
"O1C2": "H2C", # H2C dual-nozzle variant SSDP code
|
||
}
|
||
|
||
|
||
def get_ftp_profile(model: str | None) -> FTPProfile:
|
||
"""Return the :class:`FTPProfile` for *model*, or the default.
|
||
|
||
``model`` can be either a display name (e.g. ``"P2S"``) or an
|
||
internal SSDP code (e.g. ``"N7"``). Unknown / missing models fall
|
||
back to :data:`DEFAULT_PROFILE` so the FTP path is never blocked
|
||
on a missing entry.
|
||
"""
|
||
if not model:
|
||
return DEFAULT_PROFILE
|
||
key = model.upper().strip()
|
||
key = _MODEL_ALIASES.get(key, key)
|
||
return _PROFILES.get(key, DEFAULT_PROFILE)
|