Skip to content

Commit b1daf7a

Browse files
authored
Merge branch 'dev' into feature/inventory-qr-scan-to-location
2 parents 60d765e + fee16f4 commit b1daf7a

25 files changed

Lines changed: 13076 additions & 503 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -704,22 +704,21 @@ Contributions welcome! Ways to help:
704704
2. **Test** — Report issues with your printer model
705705
3. **Translate** — Add new languages
706706
4. **Code** — Submit PRs for bugs or features
707+
5. **🔒 Security review** — *(specifically wanted, see below)*
707708

708709
Not sure where to start? Reach out on [Discord](https://discord.gg/aFS3ZfScHM) or email **martin@bambuddy.cool** — I'll help you find something that fits.
709710

710-
```bash
711-
# Development setup
712-
git clone https://github.com/maziggy/bambuddy.git
713-
cd bambuddy
711+
### 🔒 Looking for a security-focused contributor
714712

715-
# Backend
716-
python3 -m venv venv && source venv/bin/activate
717-
pip install -r requirements.txt
718-
DEBUG=true uvicorn backend.app.main:app --reload
713+
I'm bringing on a contributor whose specific focus is keeping an eye on Bambuddy's security.
719714

720-
# Frontend (separate terminal)
721-
cd frontend && npm install && npm run dev
722-
```
715+
Concretely:
716+
717+
Track the `dev` branch and flag changes touching auth, permissions, token handling, or the CI security backstops. Async post-merge — no gating of in-flight PRs.
718+
719+
What matters more than formal qualifications: fail-closed thinking by default, comfortable reading the auth layer (FastAPI + SQLAlchemy on the backend, a small React surface), willing to push back on `except Exception` shapes in security-sensitive code.
720+
721+
No fixed time commitment. If you're interested — or know someone who fits — email `martin@bambuddy.cool` or DM on Discord.
723722

724723
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
725724

backend/=0.9.0

Whitespace-only changes.

backend/app/api/routes/archives.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,34 @@ def _apply_run_user_filter(conditions: list, created_by_id: int | None):
149149
conditions.append(PrintLogEntry.created_by_id == created_by_id)
150150

151151

152-
def compute_time_accuracy(archive: PrintArchive) -> dict:
152+
def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = None) -> dict:
153153
"""Compute actual print time and accuracy for an archive.
154154
155155
Returns dict with actual_time_seconds and time_accuracy.
156156
time_accuracy = (estimated / actual) * 100
157157
- 100% = perfect estimate
158158
- >100% = print was faster than estimated
159159
- <100% = print took longer than estimated
160+
161+
When ``run_aggregate`` indicates the archive has more than one logged
162+
run (multi-plate file printed plate-by-plate, or reprints), both
163+
fields are suppressed: ``archive.started_at / completed_at`` reflect
164+
the LATEST run only, while ``archive.print_time_seconds`` is the
165+
whole-file estimate (post-#1593 the parser sums across plates), so
166+
comparing the two describes different scopes. The card-rendering
167+
frontend falls through to ``archive.print_time_seconds`` for the
168+
time display and hides the badge when ``time_accuracy`` is null —
169+
that's the desired "show estimate, no badge" presentation for
170+
multi-run archives (#1608). Single-run archives keep the original
171+
badge behaviour verbatim.
160172
"""
161-
result = {"actual_time_seconds": None, "time_accuracy": None}
173+
result: dict[str, int | float | None] = {"actual_time_seconds": None, "time_accuracy": None}
174+
175+
# Multi-run archives: the per-run actual (started_at..completed_at on
176+
# the archive row) is incommensurable with the whole-file estimate.
177+
# Both fields are cleared so the card shows estimate + no badge.
178+
if run_aggregate and (run_aggregate.get("run_count") or 0) > 1:
179+
return result
162180

163181
if archive.started_at and archive.completed_at and archive.status == "completed":
164182
actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
@@ -271,8 +289,11 @@ def archive_to_response(
271289
"created_by_username": archive.created_by.username if archive.created_by else None,
272290
}
273291

274-
# Add computed time accuracy fields
275-
accuracy_data = compute_time_accuracy(archive)
292+
# Add computed time accuracy fields. ``run_aggregate`` lets
293+
# ``compute_time_accuracy`` suppress the badge for multi-run archives
294+
# where the per-run actual / whole-file estimate scopes don't match
295+
# (#1608).
296+
accuracy_data = compute_time_accuracy(archive, run_aggregate)
276297
data.update(accuracy_data)
277298

278299
if run_aggregate:
@@ -580,7 +601,10 @@ async def search_archives(
580601
query = query.limit(limit).offset(offset)
581602
result = await db.execute(query)
582603
archives = result.scalars().all()
583-
return [archive_to_response(a) for a in archives]
604+
# Load run aggregates so multi-run archives' time/accuracy badge is
605+
# suppressed consistently with the main list endpoint (#1608).
606+
run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
607+
return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
584608

585609
if not matched_ids:
586610
return []
@@ -607,7 +631,10 @@ async def search_archives(
607631
ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
608632
paginated = ordered_archives[offset : offset + limit]
609633

610-
return [archive_to_response(a) for a in paginated]
634+
# Load run aggregates so multi-run archives' time/accuracy badge is
635+
# suppressed consistently with the main list endpoint (#1608).
636+
run_aggregates = await _load_run_aggregates(db, [a.id for a in paginated])
637+
return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in paginated]
611638

612639

613640
@router.post("/search/rebuild-index")
@@ -1416,7 +1443,11 @@ async def update_archive(
14161443
)
14171444
archive = result.scalar_one_or_none()
14181445

1419-
return archive_to_response(archive)
1446+
# Load run aggregate so the time/accuracy badge stays consistent with
1447+
# the list / detail endpoints when the frontend re-renders the card
1448+
# after a PATCH (#1608).
1449+
run_aggregates = await _load_run_aggregates(db, [archive.id]) if archive else {}
1450+
return archive_to_response(archive, run_aggregate=run_aggregates.get(archive.id) if archive else None)
14201451

14211452

14221453
@router.post("/{archive_id}/favorite", response_model=ArchiveResponse)

backend/app/api/routes/projects.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,9 +701,13 @@ async def list_project_archives(
701701
archives = result.scalars().all()
702702

703703
# Import the response converter from archives module
704-
from backend.app.api.routes.archives import archive_to_response
704+
from backend.app.api.routes.archives import _load_run_aggregates, archive_to_response
705705

706-
return [archive_to_response(a) for a in archives]
706+
# Load run aggregates so multi-run archives' time/accuracy badge is
707+
# suppressed consistently with the main archives list endpoint (#1608).
708+
run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
709+
710+
return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
707711

708712

709713
@router.get("/{project_id}/queue")

backend/app/services/usage_tracker.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,14 +1223,26 @@ async def _track_from_3mf(
12231223
if isinstance(mapped, int) and mapped >= 0:
12241224
global_tray_id = mapped
12251225
# Position-based default: sort available tray IDs so external spools (254/255)
1226-
# naturally follow standard AMS trays, matching slicer slot numbering
1226+
# naturally follow standard AMS trays, matching slicer slot numbering.
1227+
#
1228+
# Filter out AMS slots that have no spool loaded (empty `tray_type`) —
1229+
# BambuStudio/OrcaSlicer compact the slot list when assigning filaments
1230+
# and don't expose empty AMS slots to the user, so the slicer's 3MF
1231+
# slot N maps to the Nth *loaded* tray, not the Nth physical position.
1232+
# Without this filter a "3 AMS slots loaded + 1 empty + external"
1233+
# layout routes the slicer's 4th filament to the empty AMS slot
1234+
# instead of the external (#1607), and the external's spool usage
1235+
# never gets recorded. vt_tray entries are already filtered the
1236+
# same way inside `build_ams_tray_lookup` (line 174 checks
1237+
# `tray_type`), so this just mirrors that for the AMS side.
12271238
if global_tray_id is None:
12281239
_state = printer_manager.get_status(printer_id)
12291240
_raw = getattr(_state, "raw_data", None) if _state else None
12301241
if _raw:
12311242
from backend.app.services.spoolman_tracking import build_ams_tray_lookup
12321243

1233-
available_trays = sorted(build_ams_tray_lookup(_raw).keys())
1244+
_lookup = build_ams_tray_lookup(_raw)
1245+
available_trays = sorted(gid for gid, info in _lookup.items() if info.get("tray_type"))
12341246
if slot_id <= len(available_trays):
12351247
global_tray_id = available_trays[slot_id - 1]
12361248
# Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)

backend/app/services/virtual_printer/bind_server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ def _create_tls_context(self) -> ssl.SSLContext | None:
7777
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
7878
ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
7979
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
80+
# Match real Bambu printer cipher behaviour: include the plain-RSA
81+
# AES-GCM suites the slicer's bind/connect path expects. On hardened
82+
# distros (Fedora / RHEL with `update-crypto-policies`, hardened Alpine
83+
# builds) the OpenSSL `DEFAULT` list strips these suites, leaving no
84+
# overlap with the slicer's ClientHello and producing `code=-1` on the
85+
# slicer side (#1610). Same fix the #620 client-side patch applied to
86+
# `tcp_proxy.py::_create_client_ssl_context`; the bind-server / server
87+
# side needs it too.
88+
ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
8089
ctx.verify_mode = ssl.CERT_NONE
8190
return ctx
8291

backend/app/services/virtual_printer/ftp_server.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,8 +642,20 @@ async def start(self) -> None:
642642
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
643643
self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
644644

645-
# Use standard TLS settings for compatibility
646-
self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
645+
# Keep the historical `HIGH:!aNULL:!MD5:!RC4` baseline so the cipher
646+
# set stays a strict superset of what shipped before (the previous
647+
# set offered ~58 extra suites — CCM, ARIA, CAMELLIA, DSS variants —
648+
# that no Bambu slicer is known to pick, but the
649+
# [[feedback_dont_remove_compat_pinning]] HARD RULE says don't
650+
# narrow a compat surface without proof). The two explicit additions
651+
# cover the #1610 case on hardened distros (Fedora / RHEL with
652+
# `update-crypto-policies`, hardened Alpine builds) where the system
653+
# policy strips the plain-RSA `AES256-GCM-SHA384` / `AES128-GCM-SHA256`
654+
# suites from `HIGH` — without them present the slicer's FTPS
655+
# ClientHello (which mimics the cipher set real Bambu printers offer)
656+
# finds no overlap and the handshake aborts. Listing them explicitly
657+
# survives any system policy that strips them from `HIGH`.
658+
self._ssl_context.set_ciphers("HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4")
647659

648660
logger.info("FTP SSL context created with standard settings")
649661

backend/app/services/virtual_printer/mqtt_bridge.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,33 @@ def _ip_to_uint32_le(ip_str: str) -> int:
9090
return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
9191

9292

93+
def _resolve_host_interface_for_target(target_ip: str) -> str | None:
94+
"""Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
95+
96+
Used when `mqtt_server.bind_address` is empty or 0.0.0.0 — the listener
97+
accepts on every interface but we still need ONE concrete IPv4 to write
98+
into the rewritten `net.info[].ip` field so the slicer's FTP target
99+
resolves to Bambuddy rather than the real printer. Returns the IPv4 of
100+
the host interface that shares a subnet with the printer (best fit
101+
because the slicer is typically on the same LAN as the printer), or
102+
None if no interface matches — in which case the bridge leaves
103+
encoding unarmed and the previous (still-leaky) behaviour stands.
104+
"""
105+
try:
106+
from backend.app.services.network_utils import find_interface_for_ip
107+
except Exception: # pragma: no cover - import shielding
108+
return None
109+
try:
110+
iface = find_interface_for_ip(target_ip)
111+
except Exception:
112+
logger.exception("MQTT bridge: find_interface_for_ip(%s) crashed", target_ip)
113+
return None
114+
if not iface:
115+
return None
116+
ip = iface.get("ip")
117+
return ip if isinstance(ip, str) and ip else None
118+
119+
93120
def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
94121
"""Merge a new ``ams`` blob from an incremental push onto the previous one.
95122
@@ -354,16 +381,31 @@ def _refresh_ip_encoding(self) -> None:
354381
printer IP, also sweep the existing cache so the slicer's next pull
355382
sees the rewritten value (#1429). Without this sweep the sticky-key
356383
preservation keeps the poisoned `net.info[].ip` alive forever.
384+
385+
VP bind IP resolution: when `mqtt_server.bind_address` is empty or
386+
`0.0.0.0` (the default for VPs that were never assigned a dedicated
387+
bind IP), fall back to auto-resolving the host interface in the same
388+
subnet as the printer's IP. Without this fallback, the rewrite never
389+
arms on a default-config flat-LAN install and `net.info[].ip` leaks
390+
the real printer IP — slicer follows it on Send (#1429 residual).
357391
"""
358392
client = self._target_client
359393
if client is None:
360394
return
361395

362396
target_ip = getattr(client, "ip_address", None)
363-
vp_ip = getattr(self._mqtt_server, "bind_address", None)
364-
if not target_ip or not vp_ip or vp_ip in ("0.0.0.0", "", None): # nosec B104
397+
if not target_ip:
365398
return
366399

400+
vp_ip = getattr(self._mqtt_server, "bind_address", None)
401+
vp_ip_source = "bind_address"
402+
if not vp_ip or vp_ip in ("0.0.0.0", ""): # nosec B104
403+
resolved = _resolve_host_interface_for_target(target_ip)
404+
if not resolved:
405+
return
406+
vp_ip = resolved
407+
vp_ip_source = "auto-resolved"
408+
367409
try:
368410
new_target_le = _ip_to_uint32_le(target_ip)
369411
new_vp_le = _ip_to_uint32_le(vp_ip)
@@ -379,11 +421,12 @@ def _refresh_ip_encoding(self) -> None:
379421
self._target_ip_uint32_le = new_target_le
380422
self._vp_ip_uint32_le = new_vp_le
381423
logger.info(
382-
"[%s] MQTT bridge IP encoding %s: target=%s vp=%s",
424+
"[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
383425
self.vp_name,
384426
"updated" if was_armed else "armed",
385427
target_ip,
386428
vp_ip,
429+
vp_ip_source,
387430
)
388431

389432
cached = self._latest_print_state

backend/app/services/virtual_printer/mqtt_server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,14 @@ async def start(self) -> None:
276276
ssl_context.verify_mode = ssl.CERT_NONE
277277
# Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
278278
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
279+
# Match real Bambu printer cipher behaviour: include the plain-RSA
280+
# AES-GCM suites the slicer expects. On hardened distros
281+
# (Fedora / RHEL with `update-crypto-policies`, hardened Alpine builds)
282+
# OpenSSL's `DEFAULT` list strips these suites, leaving no overlap
283+
# with the slicer's MQTT-over-TLS ClientHello — handshake fails
284+
# immediately and the slicer reports a connect error before any MQTT
285+
# CONNECT can be sent (#1610 audit). Same shape as the #620 fix.
286+
ssl_context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
279287
# Disable hostname checking
280288
ssl_context.check_hostname = False
281289

0 commit comments

Comments
 (0)