mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(ftp): cap TLS to v1.2 for X2D FTPS to dodge WRONG_VERSION_NUMBER on firmware 01.01.00.00 (#1638)
Reporter @vasmarfas saw X2D archive cards land almost empty - only print time, no filament weight / layers / MakerWorld link / thumbnail - and Spoolman filament-usage tracking went silent on the same printer. Support bundle traces the end-to-end: at print start backend/app/main.py::on_print_start tries the usual FTP-download dance for the 3MF, every implicit-FTPS connect attempt to the X2D fails with `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, ~2 minutes later "Could not find 3MF file for print" -> "Created fallback archive". Fallback path writes file_path="", file_size=0, content_hash=NULL, no layers / filament / model-link fields. Spoolman tracking degrades from the same root cause - both depend on the 3MF metadata parser. Proximate cause: Python 3.13's default ssl.create_default_context() negotiates TLS 1.3, the X2D's implicit-FTPS server on port 990 rejects the ClientHello. Same family as the P2S 01.02.00.00 bug from #1401 (post-Python-3.13 TLS-1.3 breakage), different wire-level failure mode (P2S completes the handshake and truncates with 426; X2D fails the handshake outright). Same fix shape: add X2D to backend/app/services/ftp_profiles.py with cap_tls_v1_2=True, plus N6 -> X2D SSDP alias. Every other model stays on negotiated TLS 1.3. Honest caveat: hypothesis-driven trial, not a confirmed root-cause fix. WRONG_VERSION_NUMBER could equally describe the X2D switching to explicit FTPS (AUTH TLS on plaintext greeting) or moving FTPS to a different port - either would need a different code path. Reporter has been asked to test this build; if the cap doesn't clear it the registry slot stays useful and the next diagnostic round goes to openssl s_client from a network-adjacent host.
This commit is contained in:
@@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **VP MQTT bridge surfaces why `net.info[].ip` rewrite didn't arm (#1429 defensive)** — `MQTTBridge._refresh_ip_encoding` had 4 silent early-return paths (`target_client is None`, `printer client has no ip_address yet`, `no host interface shares a subnet with printer IP X and bind_address is 0.0.0.0/empty`, `invalid IPv4 …`). When the rewrite silently no-op'd on a user's setup, the only signal was the absence of the `MQTT bridge IP encoding armed` INFO line — diagnosing which path was firing meant grepping the source. Each path now emits one `MQTT bridge IP encoding NOT armed: <specific reason>` INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a `_not_armed_reason` dedup field so an idle unarmed bridge doesn't spam one line per 30s refresh tick — only state changes log. Cleared on successful arm so a regression (e.g. printer client unbinds) re-emits the diagnostic. 5 new tests in `TestNotArmedDiagnosticLogging` pin each path's specific reason text, the once-per-state-change throttle, and the arm-clears-dedup behaviour. **Not a fix for #1429 itself** — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip instead of multiple.
|
||||
|
||||
### Fixed
|
||||
- **X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by @vasmarfas)** — Reporter's first archive entries from a brand-new X2D landed almost empty (only print time visible, no filament weight / layers / MakerWorld link / thumbnail), and Spoolman filament-usage tracking also went silent. The support bundle traces the symptom end-to-end: at print start `backend/app/main.py::on_print_start` tries the usual FTP-download dance for the 3MF, every connect attempt to the printer fails with `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, and ~2 minutes later `Could not find 3MF file for print: /data/Metadata/plate_1.gcode` → `Created fallback archive N for <name> (no 3MF available)`. The fallback path writes the row with `file_path=""`, `file_size=0`, `content_hash=NULL`, and no layers / filament / model-link fields — exactly the "almost empty card" in the reporter's screenshot. Spoolman tracking and reprint-grouping also degrade from the same root cause: both depend on metadata pulled out of the 3MF by `ThreeMFParser`. The proximate cause is the FTPS handshake: Python 3.13's default `ssl.create_default_context()` negotiates TLS 1.3, and the X2D's implicit-FTPS server on port 990 rejects the ClientHello with `WRONG_VERSION_NUMBER`. This is the same shape of symptom as the P2S 01.02.00.00 FTPS bug from #1401 — handshake / data-channel breakage triggered by the move to Python 3.13's TLS-1.3 default — but the wire-level failure mode is different (P2S completes the handshake and truncates mid-stream with 426; X2D fails the handshake outright). Both are addressed via the per-model registry that #1401 established: `backend/app/services/ftp_profiles.py` gains an `X2D` entry with `cap_tls_v1_2=True` plus a `N6 → X2D` SSDP alias, so the X2D's `ImplicitFTP_TLS` connection caps the SSL context's `maximum_version` to TLS 1.2 and the ClientHello looks like the one the firmware accepted before the Python upgrade. Deliberately conservative — every other model stays on negotiated TLS 1.3, only X2D-tagged sessions flip. **Honest caveat**: this ships as a hypothesis-driven trial rather than a confirmed root-cause fix. The TLS-1.2 cap is the most likely cure given the symptom's family resemblance to #1401, but `WRONG_VERSION_NUMBER` could equally describe the X2D switching to explicit FTPS (AUTH TLS on a plaintext greeting) or moving the FTPS service to a different port — both would need a different code path. The reporter has been asked to test this build; if the cap doesn't clear the error, the registry slot stays useful as a tuning anchor and the next round of diagnostics (`openssl s_client -connect <ip>:990 -tls1_2` from a network-adjacent host) will tell us which of (2)/(3) applies. **Tests**: 3 new in `test_ftp_profiles.py` mirroring the existing P2S coverage — `X2D` resolves to `cap_tls_v1_2=True`, `N6` SSDP code aliases to the X2D profile, lowercase `x2d` still hits the cap. Existing P2S + default + unknown-model + frozen-dataclass + non-capped-spot-check (X1C / H2D / P1S / A1) tests stay green. **Verified**: ruff clean; the integration test at `test_cap_tls_v1_2_actually_applied_to_ssl_context` already pins the profile→`ImplicitFTP_TLS`→`ssl_context.maximum_version` wiring so this entry can't silently fail to apply.
|
||||
|
||||
- **Label printing produced two identical PDFs per click (#1628)** — `LabelTemplatePickerModal.tsx::openBlobInNewTab` called `window.open(url, '_blank', 'noopener,noreferrer')` and treated a `null` return as "popup blocked → fall back to `<a download>` click." Per the WindowFeatures spec, `noopener` deliberately forces `window.open` to return `null` even on success, so the `if (!win)` fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (the `zo70GhSL.pdf` / `f7w0OcDi.pdf` files in the reporter's screenshot). Path 2 (fallback) downloaded a second copy named `bambuddy-labels.pdf`. Two identical PDFs per click. Fix: drop `noopener,noreferrer`. The blob is same-origin (created via `URL.createObjectURL` from our own fetch response), the destination is a passive PDF preview tab with no script context to abuse `window.opener`, and `noreferrer` is a no-op for blob URLs. After removal, `window.open` returns a real window reference on success → `if (!win)` only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases in `LabelTemplatePickerModal.test.tsx` still pass; the change is comment + one parameter.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -75,12 +75,25 @@ _PROFILES: dict[str, FTPProfile] = {
|
||||
"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]`` against Python
|
||||
# 3.13's default TLS-1.3 ClientHello (#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). Cap to TLS 1.2
|
||||
# by analogy with P2S; if the symptom turns out to be a different
|
||||
# FTPS variant on the X2D (explicit AUTH TLS, different port) the
|
||||
# entry stays useful as a per-model tuning slot for the follow-up.
|
||||
"X2D": 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,10 +43,26 @@ def test_p2s_internal_ssdp_code_resolves_to_p2s():
|
||||
assert profile.cap_tls_v1_2 is True
|
||||
|
||||
|
||||
def test_x2d_caps_tls_v1_2():
|
||||
"""X2D firmware 01.01.00.00 fails implicit-FTPS handshake on port
|
||||
990 with WRONG_VERSION_NUMBER against Python 3.13's TLS-1.3 default
|
||||
(#1638, reporter @vasmarfas). The profile caps to TLS 1.2 by
|
||||
analogy with P2S."""
|
||||
profile = get_ftp_profile("X2D")
|
||||
assert profile.cap_tls_v1_2 is True
|
||||
|
||||
|
||||
def test_x2d_internal_ssdp_code_resolves_to_x2d():
|
||||
"""SSDP internal code N6 → X2D profile."""
|
||||
profile = get_ftp_profile("N6")
|
||||
assert profile.cap_tls_v1_2 is True
|
||||
|
||||
|
||||
def test_lookup_is_case_insensitive():
|
||||
"""Printer.model may carry mixed case; the lookup normalises."""
|
||||
assert get_ftp_profile("p2s").cap_tls_v1_2 is True
|
||||
assert get_ftp_profile("P2s").cap_tls_v1_2 is True
|
||||
assert get_ftp_profile("x2d").cap_tls_v1_2 is True
|
||||
|
||||
|
||||
def test_non_capped_models_still_default():
|
||||
|
||||
Reference in New Issue
Block a user