fix(tls): declare TLS 1.2 as the minimum for printer FTPS and MQTT

ssl.create_default_context() leaves minimum_version at MINIMUM_SUPPORTED,
so the floor came from the OpenSSL build rather than from Bambuddy. On
identical OpenSSL 3.5.6, python:3.13-slim-trixie reports TLSv1_2 while a
bare-metal venv reports MINIMUM_SUPPORTED -- Docker installs were floored
at 1.2, bare-metal and appliance installs were not.

Set minimum_version explicitly in ImplicitFTP_TLS and the MQTT client. On
the P2S/X2D profiles that also cap maximum_version this becomes an exact
TLS 1.2 pin. Probed against an X1C and an H2D on :990 and :8883: both
complete only on TLS 1.2 and reject 1.0, 1.1 and 1.3; live FTPS login
through the new path succeeds on both.

Also correct a stale comment in ftp_profiles.py -- X1C and H2D refuse
TLS 1.3, so cap_tls_v1_2 is a no-op there, contrary to what it claimed.
This commit is contained in:
maziggy
2026-07-10 07:55:14 +02:00
parent d29997fa24
commit 77f8a3a3ff
4 changed files with 31 additions and 9 deletions
+1
View File
@@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green.
### Fixed
- **Printer FTPS and MQTT connections inherited their TLS floor from the OpenSSL build instead of declaring one** — `ImplicitFTP_TLS` (`bambu_ftp.py`) and the MQTT client (`bambu_mqtt.py`) both built their context with `ssl.create_default_context()`, which leaves `minimum_version` at `MINIMUM_SUPPORTED`. What that resolves to is a property of the interpreter's OpenSSL build, not of Bambuddy: measured on identical `OpenSSL 3.5.6`, the `python:3.13-slim-trixie` Docker base reports `TLSVersion.TLSv1_2` while a bare-metal venv reports `MINIMUM_SUPPORTED` — so Docker users have always been floored at TLS 1.2, while bare-metal and appliance installs could in principle negotiate TLS 1.0 or 1.1 with a printer that offered them. **Fix.** Both contexts now set `minimum_version = ssl.TLSVersion.TLSv1_2` explicitly. On the two FTP profiles that also cap `maximum_version` (P2S, X2D — see #1401) this yields an exact TLS 1.2 pin rather than a ceiling over an inherited floor. **Verified against hardware, not just tests.** Probing an X1C and an H2D on both `:990` and `:8883`, each printer completes only on TLS 1.2 and rejects 1.0, 1.1 *and* 1.3 with a `handshake_failure` alert; a live FTPS login through the changed code path succeeds on both with `TLSv1.2` negotiated. Since the shipped Docker image already enforced this floor across the whole install base, no printer or firmware reachable today can be affected by making it explicit. **Also corrected** a stale comment in `ftp_profiles.py` claiming X1C / H2D installs "stay on the negotiated TLS 1.3" — both models refuse 1.3 outright, so `cap_tls_v1_2` is a no-op there; the P2S evidently does offer 1.3, which is why it alone surfaced the vsFTPd session-reuse bug. **Scope.** Two lines plus a comment. No behaviour change on Docker, no DB migration, no new permission, no i18n change; certificate verification is unchanged (printers use self-signed certs, so `check_hostname`/`CERT_NONE` remain by necessity).
- **Backend failed to start on fastapi < 0.116: `AssertionError: Status code 204 must not have a response body`** — `uvicorn backend.app.main:app` aborted at import time while registering `DELETE /api/v1/library/tags/{tag_id}`. The route is declared `status_code=204` with a `-> None` return annotation, and `library_tags.py` uses `from __future__ import annotations` — so the annotation reaches FastAPI as the *string* `"None"`, which `get_typed_annotation()` resolves via `evaluate_forwardref()` to `NoneType`. `NoneType` is a class and therefore truthy, so `APIRoute.__init__` took the `if self.response_model:` branch and asserted that a 204 may carry no response body. fastapi **0.116** added an `if annotation is type(None): return None` guard that makes this benign, which is why CI and the Docker image (both resolve the top of the `>=0.109.0,<0.136.0` range) never saw it — only installs pinned to an older release inside that supported range, such as a venv created before the tag catalog landed in #1268, hit the crash. **Fix.** The route declares `response_model=None` explicitly, which short-circuits the annotation inference on every fastapi version in the supported range. The sibling 204 route (`DELETE /slicer/pipelines/{pipeline_id}`) is unaffected — its module has no `from __future__ import annotations` and no return annotation. **Scope.** Backend-only, one decorator. No behaviour change on fastapi >= 0.116, no DB migration, no new permission, no i18n change. Existing installs can equivalently unblock themselves with `pip install -U -r requirements.txt`.
- **Dependency floors permitted resolutions the code can't run on: `sqlalchemy>=2.0.38`, exact ruff pin** — Two more instances of the same class of defect as the 204 crash above: `requirements.txt` declared floors low enough that a legitimate `pip install -r requirements.txt` could produce an environment Bambuddy fails to start or lint in. CI never caught either, because a fresh runner always resolves to the *top* of every range — only a longer-lived venv resolving lower hits them. **sqlalchemy.** `core/database._create_engine()` passes `pool_size` / `max_overflow` on the SQLite branch. SQLAlchemy **2.0.38** changed the aiosqlite dialect's default pool for file databases from `NullPool` (which rejects both kwargs) to `AsyncAdaptedQueuePool` (which accepts them); on 2.0.0-2.0.37 the module-level `engine = _create_engine()` raises `TypeError: Invalid argument(s) 'pool_size','max_overflow' sent to create_engine()` at import, taking down every SQLite install and the whole test suite (`conftest.py` imports the module). Postgres installs were never affected — `is_sqlite()` is False and the branch is dead. Floor raised to `sqlalchemy>=2.0.38`. **ruff.** The lint job ran a bare `pip install ruff` (always the newest release) while `requirements-dev.txt` said `ruff>=0.8.0`, so CI's linter and a contributor's were routinely *different programs enforcing different rule sets*. A venv holding ruff 0.8.4 reported 32 errors against a tree current ruff calls clean — 30 of them `UP038`, a rule ruff has since **removed** (PEP 604 syntax in `isinstance()` is slower than the tuple form it wanted you to replace). ruff is now pinned exactly (`ruff==0.15.20`) and the CI lint job installs that pin from `requirements-dev.txt`, so local and CI enforce the same rules and `format --check` can't disagree across machines. **Scope.** Packaging + CI only; no application code, no DB migration, no permission, no i18n change. Existing environments should re-run `pip install -U -r requirements.txt -r requirements-dev.txt`.
- **AMS slot with a non-Bambu (no-RFID) spool showed "Empty" instead of "?" (#2527, reporter @NeighborGeek)** — When a spool without a readable RFID tag was loaded, the AMS card showed the slot as **Empty**, while Bambu Studio correctly showed a `?` for an unidentified filament. The reporter's decisive test — swapping the unknown spool between slots and watching "Empty" follow the spool, not the slot — pinned it to slot *content*, not position. Root cause: the authoritative "a spool is physically here" signal is firmware's AMS-level **`tray_exist_bits`** bitmask (what Studio uses to draw the `?`), but Bambuddy inferred emptiness from the *per-tray* `state`/`tray_type`. On the standard AMS (P1-series here, fw 01.09.00.00), a no-RFID spool is reported with an empty `tray_type` and `state=9` — structurally identical to a truly-empty slot at the tray level — so the frontend's `getEmptySlotKind()` classified it as firmware-confirmed-empty and rendered "Empty" rather than the existing `reset` kind that renders `?` ("Spool loaded — slot not configured", #1694). Confirmed from the support bundle: `tray_exist_bits='f'` (all four slots present) with `tray_is_bbl_bits='5'` (only slots 0,2 are Bambu) — slots 1,3 were present-but-non-Bambu, exactly the ones shown Empty. **Fix.** `apply_tray_exist_bits()` — which already parses the bitmask to clear stale fields on absent slots — now also annotates each slot with an authoritative `exists` bool (gated behind a new `annotate_exists` flag so only the printer-card path sets it; the VP bridge leaves it off and the `exists` key never reaches the slicer wire format). `exists` flows through the `AMSTray` schema/serialization to the frontend, where `getEmptySlotKind()` uses it: `exists === true` + no `tray_type` → `?` (present, unconfigured), `exists === false` → Empty, and `exists` absent → the previous `state=9/10` heuristic (so AMS-HT and missing-bitmask paths are unchanged). This is why the bug never reproduced on H2D or X1C — their firmware already reports present-unknown slots with a non-9 `state`, so they fell through to `reset`/`?`; with the fix they take the same path via `exists` and are unaffected. Supersedes the closed #1838. **Tests.** Backend: 3 helper cases (present/absent slots annotated, a present-no-`tray_type` slot marked `exists=true` and left uncleared, and `annotate_exists` off keeps the wire dict clean). Frontend: 1 `AmsUnitCard` case (a `state=9` slot with `exists=true` renders `?`, while `exists=false` still renders "Empty"). Full `test_bambu_mqtt` + VP-bridge suites 384/384 and the AMS/printer/VP backend selection green; `ruff` clean; `npm run build` + ESLint clean; `AmsUnitCard`/`PrintersPage` vitest green. **Scope.** No DB migration, no new permission, no i18n change; VP slicer-facing wire format unchanged.
+11
View File
@@ -64,7 +64,18 @@ class ImplicitFTP_TLS(FTP_TLS):
self.ssl_context = ssl.create_default_context()
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
# ``create_default_context()`` does NOT guarantee a protocol floor: it
# leaves ``minimum_version`` at ``MINIMUM_SUPPORTED``, and what that
# resolves to is a property of the OpenSSL build, not of this code.
# Measured on identical OpenSSL 3.5.6: python:3.13-slim-trixie (our
# Docker base) reports TLSv1_2, a bare-metal venv reports
# MINIMUM_SUPPORTED. Docker users have therefore always been floored at
# 1.2 — every Bambu model is reachable under that floor — while
# bare-metal and appliance installs could silently negotiate TLS 1.0.
# State the floor rather than inheriting it.
self.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
if cap_tls_v1_2:
# With the floor above this pins the connection to exactly TLS 1.2.
self.ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
def connect(self, host="", port=990, timeout=-999, source_address=None):
+6
View File
@@ -3596,6 +3596,12 @@ class BambuMQTTClient:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Same reasoning as ImplicitFTP_TLS in bambu_ftp.py: create_default_context()
# inherits its protocol floor from the OpenSSL build instead of declaring one.
# Every Bambu broker measured (X1C, H2D on :8883) speaks TLS 1.2 and refuses
# 1.0/1.1/1.3, so this floor is a no-op on the wire and closes the gap on
# bare-metal installs whose build allows TLS 1.0.
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
self._client.tls_set_context(ssl_context)
# Backoff reconnects to avoid tight reconnect loops on unstable brokers.
+13 -9
View File
@@ -34,10 +34,8 @@ class FTPProfile:
# Pin the SSL context's ``maximum_version`` to TLS 1.2.
#
# Python 3.13's default ``ssl.create_default_context()`` negotiates
# TLS 1.3 when both peers support it. The Bambuddy Docker image is
# ``python:3.13-slim-trixie``, so every Docker user gets 1.3 by
# default. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
# ``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
@@ -47,12 +45,18 @@ class FTPProfile:
# the printer). Capping to TLS 1.2 makes session resumption
# synchronous and the upload completes normally.
#
# Note this cap only bites on models that *offer* 1.3 in the first
# place. Probed directly on :990, an X1C and an H2D both refuse
# TLS 1.0, 1.1 and 1.3 with a handshake_failure alert and complete
# only on 1.2 — so for those models the cap is a no-op and the
# negotiated version was never 1.3. The P2S evidently does offer
# 1.3, which is why it alone surfaced the session-reuse bug.
# (P1S untested; no claim made either way.)
#
# **Defaults to False** — only applied to printer models where a
# reporter has confirmed the symptom. Existing P1S / X1C / H2D
# installs that work fine today stay on the negotiated TLS 1.3.
# This is deliberately conservative; flipping a printer to the
# capped path is a config edit when a new model surfaces the
# same bug.
# 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