diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index 103fd85..ed5a469 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -1,5 +1,24 @@ # Changelog +## [0.9.25] – 2026-06-17 + +### Behoben +- **Zufällige Abstürze / Container-Restarts — Segfault in `libcrypto.so.3` + (Issue #53).** Der MQTT-über-TLS-Client teilte einen einzelnen SSL-Socket + zwischen dem Reader-Thread (`recv`) und den Sender-Threads (`sendall`), ohne sie + zu serialisieren. CPythons `ssl`-Modul erlaubt kein gleichzeitiges Lesen und + Schreiben auf demselben Socket — die Überlappung korrumpierte den internen + OpenSSL-Zustand und löste eine Heap-Corruption + Segfault aus, die auf manchen + Hosts timing-bedingt zuverlässig auftrat. Sämtliche Socket-Zugriffe (recv / + sendall / close / reconnect) werden nun unter einem einzigen Lock serialisiert; + der Reader prüft die Bereitschaft mit `select()` außerhalb des Locks, damit die + Sender nie ausgehungert werden. Reconnect und Disconnect tauschen den Socket + jetzt atomar. Dank an @BasK für den detaillierten Fault-Handler-Trace. +- **File-Browser akzeptierte Nicht-GCode-Uploads (Issue #59).** Drag & Drop umging + den `accept`-Filter des Dateidialogs, sodass z.B. ein JPG hochgeladen werden + konnte. Uploads werden jetzt client- und serverseitig validiert; nur `.gcode`, + `.gcode.3mf`, `.3mf` und `.bgcode` werden akzeptiert. Dank an @gangoke. + ## [0.9.24] – 2026-06-16 ### Neu diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc2a1e..282b6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.9.25] – 2026-06-17 + +### Fixed +- **Random crashes / container restarts — segfault in `libcrypto.so.3` (issue #53).** + The MQTT-over-TLS client shared a single SSL socket between the reader thread + (`recv`) and the sender threads (`sendall`) without serializing them. CPython's + `ssl` module does not allow concurrent read and write on the same socket — the + overlap corrupted OpenSSL's internal state, causing a heap corruption and a + segfault that manifested reliably on some hosts (timing-dependent). All socket + access (recv / sendall / close / reconnect) is now serialized under a single + lock; the reader probes readiness with `select()` outside the lock so senders + are never starved. Reconnect and disconnect now swap the socket atomically. + Thanks to @BasK for the detailed fault-handler trace that pinpointed this. +- **File browser accepted non-GCode uploads (issue #59).** Drag & drop bypassed + the file picker's `accept` filter, so e.g. a JPG could be uploaded. Uploads are + now validated both client- and server-side; only `.gcode`, `.gcode.3mf`, `.3mf` + and `.bgcode` are accepted. Thanks @gangoke. + ## [0.9.24] – 2026-06-16 ### New diff --git a/VERSION b/VERSION index 37d8b0b..ec9b691 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.24 +0.9.25 diff --git a/kobrax_client.py b/kobrax_client.py index e7080fc..ec81391 100644 --- a/kobrax_client.py +++ b/kobrax_client.py @@ -27,6 +27,7 @@ import hashlib import json import logging import os +import select import socket import ssl import sys @@ -120,6 +121,10 @@ class KobraXClient: self._buf = b"" self._pid = 1 self._lock = threading.Lock() + # Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der + # Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm + # ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd. + self._sock_gen = 0 self._running = False # Pending requests by msgid (for response ACK) @@ -167,21 +172,31 @@ class KobraXClient: ctx.set_ciphers("DEFAULT:@SECLEVEL=0") ctx.load_cert_chain(CERT_FILE, KEY_FILE) - _ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM) - raw = socket.create_connection(_ai[0][4], timeout=5) - self._sock = ctx.wrap_socket(raw) - log.info("TLS connected cipher=%s", self._sock.cipher()[0]) + # Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK) + # läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht + # einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53). + _ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM) + raw = socket.create_connection(_ai[0][4], timeout=5) + new_sock = ctx.wrap_socket(raw) + log.info("TLS connected cipher=%s", new_sock.cipher()[0]) - self._sock.sendall(_build_connect(self.client_id, self.username, self.password)) - self._sock.settimeout(3) - r = self._sock.recv(64) + new_sock.sendall(_build_connect(self.client_id, self.username, self.password)) + new_sock.settimeout(3) + r = new_sock.recv(64) if len(r) < 4 or r[0] != 0x20 or r[3] != 0: + try: + new_sock.close() + except Exception: + pass raise RuntimeError(f"CONNACK failed: {r.hex()}") log.info("CONNACK rc=0") - self._sock.settimeout(0.2) - self._buf = b"" - self._subscribe(self._sub_topic()) + new_sock.settimeout(0.2) + with self._lock: + self._sock = new_sock + self._sock_gen += 1 + self._buf = b"" + self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln log.debug("MQTT connected to %s:%s", self.host, self.port) def connect(self): @@ -207,10 +222,14 @@ class KobraXClient: def disconnect(self): self._running = False - try: - self._sock.close() - except Exception: - pass + with self._lock: + try: + if self._sock is not None: + self._sock.close() + except Exception: + pass + self._sock = None + self._sock_gen += 1 def _reconnect(self): """Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder @@ -219,10 +238,16 @@ class KobraXClient: nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht ausgeschaltet) zu vermeiden.""" log.warning("Verbindung verloren – reconnect…") - try: - self._sock.close() - except Exception: - pass + # Close + Invalidierung unter Lock, damit kein Sender mitten im sendall + # auf den gerade geschlossenen Socket trifft (Issue #53). + with self._lock: + try: + if self._sock is not None: + self._sock.close() + except Exception: + pass + self._sock = None + self._sock_gen += 1 delays = [2, 4, 8, 15, 30, 60] attempt = 0 while self._running: @@ -246,7 +271,8 @@ class KobraXClient: with self._lock: pid = self._pid self._pid += 1 - self._sock.sendall(_build_subscribe(topic, pid)) + if self._sock is not None: + self._sock.sendall(_build_subscribe(topic, pid)) log.info("SUB %s", topic) # -- Read loop ----------------------------------------------------------- @@ -256,17 +282,52 @@ class KobraXClient: _empty_count = 0 while self._running: if time.time() - last_ping > 30: + ping_ok = False with self._lock: try: - self._sock.sendall(_build_pingreq()) + if self._sock is not None: + self._sock.sendall(_build_pingreq()) + ping_ok = True except Exception: - if self._running and not self._reconnect(): - break - last_ping = time.time() - continue + ping_ok = False + # _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock + # selbst, und threading.Lock ist nicht reentrant (sonst Deadlock). + if not ping_ok: + if self._running and not self._reconnect(): + break last_ping = time.time() + # Aktuellen Socket + Generation unter Lock greifen, damit ein + # paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale + # fd pollen lässt (Issue #53). + with self._lock: + sock = self._sock + gen = self._sock_gen + if sock is None: + time.sleep(0.05) + continue + + # Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so + # blockiert der Reader während Leerlauf nie das gemeinsame Lock. try: - data = self._sock.recv(65536) + ready, _, _ = select.select([sock], [], [], 0.2) + except (OSError, ValueError): + # fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select) + if not self._running: + break + time.sleep(0.05) + continue + if not ready: + continue # Leerlauf, kein Lock gehalten + + # Daten liegen an: Lock kurz greifen für das eine recv, serialisiert + # gegen alle sendall-Caller. recv blockiert nicht lange (select sagte + # ready, Socket-Timeout ist 0.2s). + try: + with self._lock: + # Socket könnte zwischen select und hier ersetzt worden sein. + if self._sock_gen != gen or self._sock is not sock: + continue + data = sock.recv(65536) if not data: # Windows SSL kann kurzzeitig b"" liefern ohne echten EOF _empty_count += 1 @@ -275,7 +336,7 @@ class KobraXClient: continue _empty_count = 0 self._buf += data - self._drain() + self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt except ssl.SSLWantReadError: continue except socket.timeout: diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index d9e25e5..774e7e5 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -2925,6 +2925,18 @@ class KobraXBridge: if not file_data: return web.json_response({"error": "no file received"}, status=400) + # Nur druckbare Dateien zulassen (Issue #59) — verhindert dass z.B. ein + # JPG im File-Browser landet. OrcaSlicer-Uploads sind .gcode/.gcode.3mf, + # der Kobra X akzeptiert .gcode/.3mf/.bgcode. + _allowed_ext = (".gcode", ".gcode.3mf", ".3mf", ".bgcode") + _fn_lower = (remote_filename or "").lower() + if not _fn_lower.endswith(_allowed_ext): + log.warning(f"Upload abgelehnt (kein GCode): {remote_filename}") + return web.json_response( + {"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"}, + status=400, + ) + file_md5 = hashlib.md5(file_data).hexdigest() file_size = len(file_data) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 20a381b..02e0de0 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -2006,6 +2006,14 @@ function uploadGcode(file){ var zone=document.getElementById('store-upload-zone'); var status=document.getElementById('store-upload-status'); var label=document.getElementById('store-upload-label'); + // Nur druckbare Dateien zulassen (Issue #59) — Drag&Drop umgeht das + // accept-Attribut, daher hier explizit prüfen. + var _fn=(file.name||'').toLowerCase(); + if(!/\.(gcode|gcode\.3mf|3mf|bgcode)$/.test(_fn)){ + if(status){ status.textContent=T.store_upload_only_gcode||'Only GCode files allowed'; status.style.display=''; status.className='upload-status-err'; } + clog('Upload abgelehnt (kein GCode): '+file.name,'msg-err'); + return; + } if(status) { status.textContent=T.store_upload_busy; status.style.display=''; status.className='upload-status-busy'; } if(label) label.style.display='none'; if(zone) zone.style.pointerEvents='none'; diff --git a/web/translations/de.json b/web/translations/de.json index c11dda1..c5011df 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -252,6 +252,7 @@ "store_upload_busy": "⏳ Hochladen…", "store_upload_success": "✓ {file}", "store_upload_error": "✗ {error}", + "store_upload_only_gcode": "✗ Nur GCode-Dateien erlaubt (.gcode, .3mf, .bgcode)", "sf_all": "Alle", "sf_ok": "✓ Erfolgreich", "sf_err": "✗ Fehler", diff --git a/web/translations/en.json b/web/translations/en.json index 3e1b1e8..61c5c86 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -280,6 +280,7 @@ "store_upload_busy": "⏳ Uploading…", "store_upload_success": "✓ {file}", "store_upload_error": "✗ {error}", + "store_upload_only_gcode": "✗ Only GCode files allowed (.gcode, .3mf, .bgcode)", "sf_all": "All", "sf_ok": "✓ Completed", "sf_err": "✗ Failed", diff --git a/web/translations/es.json b/web/translations/es.json index 6544dbf..822cf4b 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -252,6 +252,7 @@ "store_upload_busy": "⏳ Subiendo…", "store_upload_success": "✓ {file}", "store_upload_error": "✗ {error}", + "store_upload_only_gcode": "✗ Solo se permiten archivos GCode (.gcode, .3mf, .bgcode)", "sf_all": "Todos", "sf_ok": "✓ Completado", "sf_err": "✗ Fallido", diff --git a/web/translations/fr.json b/web/translations/fr.json index e1a4c88..01fc6f6 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -252,6 +252,7 @@ "store_upload_busy": "⏳ Envoi en cours…", "store_upload_success": "✓ {file}", "store_upload_error": "✗ {error}", + "store_upload_only_gcode": "✗ Seuls les fichiers GCode sont autorisés (.gcode, .3mf, .bgcode)", "sf_all": "Tout", "sf_ok": "✓ Terminés", "sf_err": "✗ Échoués", diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index af584d3..8225ee0 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -252,6 +252,7 @@ "store_upload_busy": "⏳ 上传中…", "store_upload_success": "✓ {file}", "store_upload_error": "✗ {error}", + "store_upload_only_gcode": "✗ 仅允许 GCode 文件 (.gcode, .3mf, .bgcode)", "sf_all": "全部", "sf_ok": "✓ 已完成", "sf_err": "✗ 失败",