From 34eac462bd5655e2b8075966afab6cd46fd55973 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 23 Jul 2025 16:58:09 +0200 Subject: [PATCH 1/8] More authentication, since printer-API call-responses can include user-info. The new regulations make a decent amount of sense -- but just because we agree with them doesn't mean we'd implemented this yet. Anyway, information wich can be used to personally identify people should be kept behind (virtual) locks and bars. The new firmware will only allow certain operations _after_ a request has been made to the .../auth/request endpoint, and someone in the physical vicinity (of the printer) has pressed ALLOW on a popup (with the application and name of the requester shown, on the printers' UI). After that, _as long as you put the relevant Authorization Digest in your HTTP headers_ (and use at least SHA-256), you may proceed to make other requests without the printer-server flipping out with a FORBIDDEN error. The current commit _should_ also still work with printers that still have old (well, current I guess...) firmware -- but I didn't test that yet. CURA-12624 --- .../src/Network/ClusterApiClient.py | 90 +++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index fd8118306b..a1f7a47da6 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -1,6 +1,8 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. +import hashlib import json +import secrets from json import JSONDecodeError from typing import Callable, List, Optional, Dict, Union, Any, Type, cast, TypeVar, Tuple @@ -9,6 +11,8 @@ from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkRepl from UM.Logger import Logger +from cura.CuraApplication import CuraApplication + from ..Models.BaseModel import BaseModel from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus @@ -27,6 +31,14 @@ class ClusterApiClient: PRINTER_API_PREFIX = "/api/v1" CLUSTER_API_PREFIX = "/cluster-api/v1" + AUTH_REALM = "Jedi-API" + AUTH_QOP = "auth" + AUTH_NC = "00000001" + AUTH_NONCE_LEN = 16 + AUTH_CNONCE_LEN = 8 + + AUTH_MAX_TRIES = 5 + # In order to avoid garbage collection we keep the callbacks in this list. _anti_gc_callbacks = [] # type: List[Callable[[], None]] @@ -40,6 +52,8 @@ class ClusterApiClient: self._manager = QNetworkAccessManager() self._address = address self._on_error = on_error + self._auth_info = None + self._auth_tries = 0 def getSystem(self, on_finished: Callable) -> None: """Get printer system information. @@ -81,13 +95,13 @@ class ClusterApiClient: """Move a print job to the top of the queue.""" url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.post(self._createEmptyRequest(url), json.dumps({"to_position": 0, "list": "queued"}).encode()) + self._manager.post(self._createEmptyRequest(url, method="POST"), json.dumps({"to_position": 0, "list": "queued"}).encode()) def forcePrintJob(self, print_job_uuid: str) -> None: """Override print job configuration and force it to be printed.""" url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.put(self._createEmptyRequest(url), json.dumps({"force": True}).encode()) + self._manager.put(self._createEmptyRequest(url, method="PUT"), json.dumps({"force": True}).encode()) def deletePrintJob(self, print_job_uuid: str) -> None: """Delete a print job from the queue.""" @@ -101,7 +115,7 @@ class ClusterApiClient: url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid) # We rewrite 'resume' to 'print' here because we are using the old print job action endpoints. action = "print" if state == "resume" else state - self._manager.put(self._createEmptyRequest(url), json.dumps({"action": action}).encode()) + self._manager.put(self._createEmptyRequest(url, method="PUT"), json.dumps({"action": action}).encode()) def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None: """Get the preview image data of a print job.""" @@ -110,16 +124,23 @@ class ClusterApiClient: reply = self._manager.get(self._createEmptyRequest(url)) self._addCallback(reply, on_finished) - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json", method: str = "GET", skip_auth: bool = False) -> QNetworkRequest: """We override _createEmptyRequest in order to add the user credentials. :param url: The URL to request :param content_type: The type of the body contents. + :param method: The HTTP method to use, such as GET, POST, PUT, etc. + :param skip_auth: Skips the authentication step if set; prevents a loop on request of authentication token. """ url = QUrl("http://" + self._address + path) request = QNetworkRequest(url) if content_type: request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, content_type) + if self._auth_info: + digest_str = self._makeAuthDigestHeaderPart(path, method=method) + request.setRawHeader(b"Authorization", f"Digest {digest_str}".encode("utf-8")) + elif not skip_auth: + self._setupAuth() return request @staticmethod @@ -158,6 +179,65 @@ class ClusterApiClient: except (JSONDecodeError, TypeError, ValueError): Logger.log("e", "Could not parse response from network: %s", str(response)) + def _makeAuthDigestHeaderPart(self, url_part: str, method: str = "GET") -> str: + """ Make the data-part for a Digest Authentication HTTP-header. + + :param url_part: The part of the URL beyond the host name. + :param method: The HTTP method to use, such as GET, POST, PUT, etc. + :return: A string with the data, can be used as in `f"Digest {return_value}".encode()`. + """ + + def sha256_utf8(x: str) -> str: + return hashlib.sha256(x.encode("utf-8")).hexdigest() + + nonce = secrets.token_hex(ClusterApiClient.AUTH_NONCE_LEN) + cnonce = secrets.token_hex(ClusterApiClient.AUTH_CNONCE_LEN) + + ha1 = sha256_utf8(f"{self._auth_info["id"]}:{ClusterApiClient.AUTH_REALM}:{self._auth_info["key"]}") + ha2 = sha256_utf8(f"{method}:{url_part}") + resp_digest = sha256_utf8(f"{ha1}:{nonce}:{ClusterApiClient.AUTH_NC}:{cnonce}:{ClusterApiClient.AUTH_QOP}:{ha2}") + return ", ".join([ + f'username="{self._auth_info["id"]}"', + f'realm="{ClusterApiClient.AUTH_REALM}"', + f'nonce="{nonce}"', + f'uri="{url_part}"', + f'nc={ClusterApiClient.AUTH_NC}', + f'cnonce="{cnonce}"', + f'qop={ClusterApiClient.AUTH_QOP}', + f'response="{resp_digest}"', + f'algorithm="SHA-256"' + ]) + + def _setupAuth(self) -> None: + """ Handles the setup process for authentication by making a temporary digest-token request to the printer API. + """ + + if self._auth_tries >= ClusterApiClient.AUTH_MAX_TRIES: + Logger.warning("Maximum authorization temporary digest-token request tries exceeded. Is printer-firmware up to date?") + return + + username = CuraApplication.getInstance().getCuraAPI().account.userName + if (not username) or username == "": + return + + def on_finished(resp) -> None: + self._auth_tries += 1 + try: + self._auth_info = json.loads(resp.data().decode()) + except Exception as ex: + Logger.warning(f"Couldn't get temporary digest token: {str(ex)}") + return + self._auth_tries = 0 + + url = "{}/auth/request".format(self.PRINTER_API_PREFIX) + request_body = json.dumps({ + "application": CuraApplication.getInstance().getApplicationDisplayName(), + "user": username, + }).encode("utf-8") + reply = self._manager.post(self._createEmptyRequest(url, method="POST", skip_auth=True), request_body) + + self._addCallback(reply, on_finished) + def _addCallback(self, reply: QNetworkReply, on_finished: Union[Callable[[ClusterApiClientModel], Any], Callable[[List[ClusterApiClientModel]], Any]], model: Type[ClusterApiClientModel] = None, ) -> None: From 115d2d5b775901a966aa66ff0ae1393acb4dab48 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 23 Jul 2025 18:19:17 +0200 Subject: [PATCH 2/8] Fix 2 calls w.r.t. new authorization workflow _outside_ of the API. New rules means we have to put printjobs and such behind a little authentication, as these contain personally identifiable info. These two effected calls where found _outside_ of the API class where I thought to be able to fix it 100%. See also the TODO's in the neighbourhood -- but I'm not sure I can just do what those say (move the relevant methods to the API), as those methods to be moved are _inside_ the larger Cura SDK (and they're public) and the place where I'm meant to move them to (the ClusterAPIClient) is _not_ (as they're in a plugin). part of CURA-12624 --- .../NetworkedPrinterOutputDevice.py | 6 +++-- .../src/Network/ClusterApiClient.py | 22 +++++++++---------- .../src/Network/LocalClusterOutputDevice.py | 3 ++- .../src/Network/SendMaterialJob.py | 3 ++- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 1d0be1389e..0eb55d81c5 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -288,9 +288,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply: + on_progress: Optional[Callable[[int, int], None]] = None, + request: Optional[QNetworkRequest] = None) -> QNetworkReply: self._validateManager() - request = self._createEmptyRequest(target, content_type=None) + if request is None: + request = self._createEmptyRequest(target, content_type=None) multi_post_part = QHttpMultiPart(QHttpMultiPart.ContentType.FormDataType) for part in parts: multi_post_part.append(part) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index a1f7a47da6..ed92b4aafe 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -61,7 +61,7 @@ class ClusterApiClient: :param on_finished: The callback in case the response is successful. """ url = "{}/system".format(self.PRINTER_API_PREFIX) - reply = self._manager.get(self._createEmptyRequest(url)) + reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished, PrinterSystemStatus) def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None: @@ -70,7 +70,7 @@ class ClusterApiClient: :param on_finished: The callback in case the response is successful. """ url = "{}/materials".format(self.CLUSTER_API_PREFIX) - reply = self._manager.get(self._createEmptyRequest(url)) + reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished, ClusterMaterial) def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None: @@ -79,7 +79,7 @@ class ClusterApiClient: :param on_finished: The callback in case the response is successful. """ url = "{}/printers".format(self.CLUSTER_API_PREFIX) - reply = self._manager.get(self._createEmptyRequest(url)) + reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished, ClusterPrinterStatus) def getPrintJobs(self, on_finished: Callable[[List[ClusterPrintJobStatus]], Any]) -> None: @@ -88,26 +88,26 @@ class ClusterApiClient: :param on_finished: The callback in case the response is successful. """ url = "{}/print_jobs".format(self.CLUSTER_API_PREFIX) - reply = self._manager.get(self._createEmptyRequest(url)) + reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished, ClusterPrintJobStatus) def movePrintJobToTop(self, print_job_uuid: str) -> None: """Move a print job to the top of the queue.""" url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.post(self._createEmptyRequest(url, method="POST"), json.dumps({"to_position": 0, "list": "queued"}).encode()) + self._manager.post(self.createEmptyRequest(url, method="POST"), json.dumps({"to_position": 0, "list": "queued"}).encode()) def forcePrintJob(self, print_job_uuid: str) -> None: """Override print job configuration and force it to be printed.""" url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.put(self._createEmptyRequest(url, method="PUT"), json.dumps({"force": True}).encode()) + self._manager.put(self.createEmptyRequest(url, method="PUT"), json.dumps({"force": True}).encode()) def deletePrintJob(self, print_job_uuid: str) -> None: """Delete a print job from the queue.""" url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.deleteResource(self._createEmptyRequest(url)) + self._manager.deleteResource(self.createEmptyRequest(url)) def setPrintJobState(self, print_job_uuid: str, state: str) -> None: """Set the state of a print job.""" @@ -115,16 +115,16 @@ class ClusterApiClient: url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid) # We rewrite 'resume' to 'print' here because we are using the old print job action endpoints. action = "print" if state == "resume" else state - self._manager.put(self._createEmptyRequest(url, method="PUT"), json.dumps({"action": action}).encode()) + self._manager.put(self.createEmptyRequest(url, method="PUT"), json.dumps({"action": action}).encode()) def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None: """Get the preview image data of a print job.""" url = "{}/print_jobs/{}/preview_image".format(self.CLUSTER_API_PREFIX, print_job_uuid) - reply = self._manager.get(self._createEmptyRequest(url)) + reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished) - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json", method: str = "GET", skip_auth: bool = False) -> QNetworkRequest: + def createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json", method: str = "GET", skip_auth: bool = False) -> QNetworkRequest: """We override _createEmptyRequest in order to add the user credentials. :param url: The URL to request @@ -234,7 +234,7 @@ class ClusterApiClient: "application": CuraApplication.getInstance().getApplicationDisplayName(), "user": username, }).encode("utf-8") - reply = self._manager.post(self._createEmptyRequest(url, method="POST", skip_auth=True), request_body) + reply = self._manager.post(self.createEmptyRequest(url, method="POST", skip_auth=True), request_body) self._addCallback(reply, on_finished) diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 2a57bd0321..f9e0b95b59 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -204,7 +204,8 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): parts.append(self._createFormPart("name=require_printer_name", bytes(unique_name, "utf-8"), "text/plain")) # FIXME: move form posting to API client self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, - on_progress=self._onPrintJobUploadProgress) + on_progress=self._onPrintJobUploadProgress, + request=self._cluster_api.createEmptyRequest("/cluster-api/v1/print_jobs/", content_type=None, method="POST")) self._active_exported_job = None def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py index 9f5484ba15..2f3fb9ff19 100644 --- a/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py @@ -147,7 +147,8 @@ class SendMaterialJob(Job): # FIXME: move form posting to API client self.device.postFormWithParts(target = "/cluster-api/v1/materials/", parts = parts, - on_finished = self._sendingFinished) + on_finished = self._sendingFinished, + request=self._cluster_api.createEmptyRequest("/cluster-api/v1/materials/", content_type=None, method="POST")) def _sendingFinished(self, reply: QNetworkReply) -> None: """Check a reply from an upload to the printer and log an error when the call failed""" From 7f35a5074b3dc3f417cc7a5f5cd311bbd120b8da Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 23 Jul 2025 20:25:17 +0200 Subject: [PATCH 3/8] Make authentication info a little less brittle. Otherwise if the server (on the printer) gives back something that can be parsed into JSON, but _isn't_ the authorization digest info, the thing breaks. part of CURA-12624 --- .../src/Network/ClusterApiClient.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index ed92b4aafe..c7562db96b 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -52,7 +52,8 @@ class ClusterApiClient: self._manager = QNetworkAccessManager() self._address = address self._on_error = on_error - self._auth_info = None + self._auth_id = None + self._auth_key = None self._auth_tries = 0 def getSystem(self, on_finished: Callable) -> None: @@ -136,7 +137,7 @@ class ClusterApiClient: request = QNetworkRequest(url) if content_type: request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, content_type) - if self._auth_info: + if self._auth_id and self._auth_key: digest_str = self._makeAuthDigestHeaderPart(path, method=method) request.setRawHeader(b"Authorization", f"Digest {digest_str}".encode("utf-8")) elif not skip_auth: @@ -193,11 +194,11 @@ class ClusterApiClient: nonce = secrets.token_hex(ClusterApiClient.AUTH_NONCE_LEN) cnonce = secrets.token_hex(ClusterApiClient.AUTH_CNONCE_LEN) - ha1 = sha256_utf8(f"{self._auth_info["id"]}:{ClusterApiClient.AUTH_REALM}:{self._auth_info["key"]}") + ha1 = sha256_utf8(f"{self._auth_id}:{ClusterApiClient.AUTH_REALM}:{self._auth_key}") ha2 = sha256_utf8(f"{method}:{url_part}") resp_digest = sha256_utf8(f"{ha1}:{nonce}:{ClusterApiClient.AUTH_NC}:{cnonce}:{ClusterApiClient.AUTH_QOP}:{ha2}") return ", ".join([ - f'username="{self._auth_info["id"]}"', + f'username="{self._auth_id}"', f'realm="{ClusterApiClient.AUTH_REALM}"', f'nonce="{nonce}"', f'uri="{url_part}"', @@ -223,7 +224,9 @@ class ClusterApiClient: def on_finished(resp) -> None: self._auth_tries += 1 try: - self._auth_info = json.loads(resp.data().decode()) + auth_info = json.loads(resp.data().decode()) + self._auth_id = auth_info["id"] + self._auth_key = auth_info["key"] except Exception as ex: Logger.warning(f"Couldn't get temporary digest token: {str(ex)}") return From 75fc0782da38f591796a2885a29c4ecaedbd9a0d Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 29 Jul 2025 11:00:15 +0200 Subject: [PATCH 4/8] Code review: Replace string with enum. First use of 3.11's StrEnum in the code base I think -- anyway, Python autoboxes these (maybe even the old str,enum things as well, but irrelevant now), so there's nothing in the way of making this an enum and have type-_checking_ instead of type-_o_'s. done as part of CURA-12624 --- .../src/Network/ClusterApiClient.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index c7562db96b..5cd8457188 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -3,6 +3,7 @@ import hashlib import json import secrets +from enum import StrEnum from json import JSONDecodeError from typing import Callable, List, Optional, Dict, Union, Any, Type, cast, TypeVar, Tuple @@ -24,6 +25,18 @@ ClusterApiClientModel = TypeVar("ClusterApiClientModel", bound=BaseModel) """The generic type variable used to document the methods below.""" +class HttpRequestMethod(StrEnum): + GET = "GET", + HEAD = "HEAD", + POST = "POST", + PUT = "PUT", + DELETE = "DELETE", + CONNECT = "CONNECT", + OPTIONS = "OPTIONS", + TRACE = "TRACE", + PATCH = "PATCH", + + class ClusterApiClient: """The ClusterApiClient is responsible for all network calls to local network clusters.""" @@ -96,13 +109,13 @@ class ClusterApiClient: """Move a print job to the top of the queue.""" url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.post(self.createEmptyRequest(url, method="POST"), json.dumps({"to_position": 0, "list": "queued"}).encode()) + self._manager.post(self.createEmptyRequest(url, method=HttpRequestMethod.POST), json.dumps({"to_position": 0, "list": "queued"}).encode()) def forcePrintJob(self, print_job_uuid: str) -> None: """Override print job configuration and force it to be printed.""" url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.put(self.createEmptyRequest(url, method="PUT"), json.dumps({"force": True}).encode()) + self._manager.put(self.createEmptyRequest(url, method=HttpRequestMethod.PUT), json.dumps({"force": True}).encode()) def deletePrintJob(self, print_job_uuid: str) -> None: """Delete a print job from the queue.""" @@ -116,7 +129,7 @@ class ClusterApiClient: url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid) # We rewrite 'resume' to 'print' here because we are using the old print job action endpoints. action = "print" if state == "resume" else state - self._manager.put(self.createEmptyRequest(url, method="PUT"), json.dumps({"action": action}).encode()) + self._manager.put(self.createEmptyRequest(url, method=HttpRequestMethod.PUT), json.dumps({"action": action}).encode()) def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None: """Get the preview image data of a print job.""" @@ -125,10 +138,10 @@ class ClusterApiClient: reply = self._manager.get(self.createEmptyRequest(url)) self._addCallback(reply, on_finished) - def createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json", method: str = "GET", skip_auth: bool = False) -> QNetworkRequest: + def createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json", method: HttpRequestMethod = HttpRequestMethod.GET, skip_auth: bool = False) -> QNetworkRequest: """We override _createEmptyRequest in order to add the user credentials. - :param url: The URL to request + :param path: Part added to the base-endpoint forming the total request URL (the path from the endpoint to the requested resource). :param content_type: The type of the body contents. :param method: The HTTP method to use, such as GET, POST, PUT, etc. :param skip_auth: Skips the authentication step if set; prevents a loop on request of authentication token. @@ -180,7 +193,7 @@ class ClusterApiClient: except (JSONDecodeError, TypeError, ValueError): Logger.log("e", "Could not parse response from network: %s", str(response)) - def _makeAuthDigestHeaderPart(self, url_part: str, method: str = "GET") -> str: + def _makeAuthDigestHeaderPart(self, url_part: str, method: HttpRequestMethod = HttpRequestMethod.GET) -> str: """ Make the data-part for a Digest Authentication HTTP-header. :param url_part: The part of the URL beyond the host name. @@ -237,7 +250,7 @@ class ClusterApiClient: "application": CuraApplication.getInstance().getApplicationDisplayName(), "user": username, }).encode("utf-8") - reply = self._manager.post(self.createEmptyRequest(url, method="POST", skip_auth=True), request_body) + reply = self._manager.post(self.createEmptyRequest(url, method=HttpRequestMethod.POST, skip_auth=True), request_body) self._addCallback(reply, on_finished) From 6b1f29cdb1f25aa12303fa88c40864c56e5df3bc Mon Sep 17 00:00:00 2001 From: Frederic Meeuwissen <13856291+Frederic98@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:12:07 +0200 Subject: [PATCH 5/8] Fix crash on AttributeError --- .../src/Network/LocalClusterOutputDevice.py | 20 +++++++++---------- .../src/Network/SendMaterialJob.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index f9e0b95b59..f51ff5a4e8 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -94,15 +94,15 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): @pyqtSlot(str, name="sendJobToTop") def sendJobToTop(self, print_job_uuid: str) -> None: - self._getApiClient().movePrintJobToTop(print_job_uuid) + self.getApiClient().movePrintJobToTop(print_job_uuid) @pyqtSlot(str, name="deleteJobFromQueue") def deleteJobFromQueue(self, print_job_uuid: str) -> None: - self._getApiClient().deletePrintJob(print_job_uuid) + self.getApiClient().deletePrintJob(print_job_uuid) @pyqtSlot(str, name="forceSendJob") def forceSendJob(self, print_job_uuid: str) -> None: - self._getApiClient().forcePrintJob(print_job_uuid) + self.getApiClient().forcePrintJob(print_job_uuid) def setJobState(self, print_job_uuid: str, action: str) -> None: """Set the remote print job state. @@ -111,20 +111,20 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): :param action: The action to undertake ('pause', 'resume', 'abort'). """ - self._getApiClient().setPrintJobState(print_job_uuid, action) + self.getApiClient().setPrintJobState(print_job_uuid, action) def _update(self) -> None: super()._update() if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL: return # avoid calling the cluster too often - self._getApiClient().getPrinters(self._updatePrinters) - self._getApiClient().getPrintJobs(self._updatePrintJobs) + self.getApiClient().getPrinters(self._updatePrinters) + self.getApiClient().getPrintJobs(self._updatePrintJobs) self._updatePrintJobPreviewImages() def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None: """Get a list of materials that are installed on the cluster host.""" - self._getApiClient().getMaterials(on_finished = on_finished) + self.getApiClient().getMaterials(on_finished = on_finished) def sendMaterialProfiles(self) -> None: """Sync the material profiles in Cura with the printer. @@ -205,7 +205,7 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): # FIXME: move form posting to API client self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, on_progress=self._onPrintJobUploadProgress, - request=self._cluster_api.createEmptyRequest("/cluster-api/v1/print_jobs/", content_type=None, method="POST")) + request=self.getApiClient().createEmptyRequest("/cluster-api/v1/print_jobs/", content_type=None, method="POST")) self._active_exported_job = None def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: @@ -237,9 +237,9 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): for print_job in self._print_jobs: if print_job.getPreviewImage() is None: - self._getApiClient().getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData) + self.getApiClient().getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData) - def _getApiClient(self) -> ClusterApiClient: + def getApiClient(self) -> ClusterApiClient: """Get the API client instance.""" if not self._cluster_api: diff --git a/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py index 2f3fb9ff19..3b9f127c77 100644 --- a/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py @@ -148,7 +148,7 @@ class SendMaterialJob(Job): # FIXME: move form posting to API client self.device.postFormWithParts(target = "/cluster-api/v1/materials/", parts = parts, on_finished = self._sendingFinished, - request=self._cluster_api.createEmptyRequest("/cluster-api/v1/materials/", content_type=None, method="POST")) + request=self.device.getApiClient().createEmptyRequest("/cluster-api/v1/materials/", content_type=None, method="POST")) def _sendingFinished(self, reply: QNetworkReply) -> None: """Check a reply from an upload to the printer and log an error when the call failed""" From 7fc87cb4c126bf77d3104a379f53fc6755544d6e Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 3 Sep 2025 14:28:12 +0200 Subject: [PATCH 6/8] Fill in correct nonce and nonce-count for cluster-auth. part of CURA-12624 --- .../src/Network/ClusterApiClient.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index 5cd8457188..1a0a915a03 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import hashlib import json +import re import secrets from enum import StrEnum from json import JSONDecodeError @@ -46,7 +47,6 @@ class ClusterApiClient: AUTH_REALM = "Jedi-API" AUTH_QOP = "auth" - AUTH_NC = "00000001" AUTH_NONCE_LEN = 16 AUTH_CNONCE_LEN = 8 @@ -69,6 +69,9 @@ class ClusterApiClient: self._auth_key = None self._auth_tries = 0 + self._nonce_count = 1 + self._nonce = None + def getSystem(self, on_finished: Callable) -> None: """Get printer system information. @@ -153,6 +156,7 @@ class ClusterApiClient: if self._auth_id and self._auth_key: digest_str = self._makeAuthDigestHeaderPart(path, method=method) request.setRawHeader(b"Authorization", f"Digest {digest_str}".encode("utf-8")) + self._nonce_count += 1 elif not skip_auth: self._setupAuth() return request @@ -204,18 +208,19 @@ class ClusterApiClient: def sha256_utf8(x: str) -> str: return hashlib.sha256(x.encode("utf-8")).hexdigest() - nonce = secrets.token_hex(ClusterApiClient.AUTH_NONCE_LEN) + nonce = secrets.token_hex(ClusterApiClient.AUTH_NONCE_LEN) if self._nonce is None else self._nonce cnonce = secrets.token_hex(ClusterApiClient.AUTH_CNONCE_LEN) + auth_nc = f"{self._nonce_count:08x}" ha1 = sha256_utf8(f"{self._auth_id}:{ClusterApiClient.AUTH_REALM}:{self._auth_key}") ha2 = sha256_utf8(f"{method}:{url_part}") - resp_digest = sha256_utf8(f"{ha1}:{nonce}:{ClusterApiClient.AUTH_NC}:{cnonce}:{ClusterApiClient.AUTH_QOP}:{ha2}") + resp_digest = sha256_utf8(f"{ha1}:{nonce}:{auth_nc}:{cnonce}:{ClusterApiClient.AUTH_QOP}:{ha2}") return ", ".join([ f'username="{self._auth_id}"', f'realm="{ClusterApiClient.AUTH_REALM}"', f'nonce="{nonce}"', f'uri="{url_part}"', - f'nc={ClusterApiClient.AUTH_NC}', + f'nc={auth_nc}', f'cnonce="{cnonce}"', f'qop={ClusterApiClient.AUTH_QOP}', f'response="{resp_digest}"', @@ -275,6 +280,11 @@ class ClusterApiClient: return if reply.error() != QNetworkReply.NetworkError.NoError: + if reply.error() == QNetworkReply.NetworkError.AuthenticationRequiredError: + nonce_match = re.search(r'nonce="([^"]+)', str(reply.rawHeader(b"WWW-Authenticate"))) + if nonce_match: + self._nonce = nonce_match.group(1) + self._nonce_count = 1 self._on_error(reply.errorString()) return From 70a8f9b0a3f816f6ce0de34fe22fff5c086a608c Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 3 Sep 2025 14:37:18 +0200 Subject: [PATCH 7/8] Use machine-node-ID as username. Otherwise there's _still_ personal information in there. part of CURA-12624 --- plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index 1a0a915a03..bbe7932d66 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import hashlib import json +import platform import re import secrets from enum import StrEnum @@ -235,10 +236,6 @@ class ClusterApiClient: Logger.warning("Maximum authorization temporary digest-token request tries exceeded. Is printer-firmware up to date?") return - username = CuraApplication.getInstance().getCuraAPI().account.userName - if (not username) or username == "": - return - def on_finished(resp) -> None: self._auth_tries += 1 try: @@ -253,7 +250,7 @@ class ClusterApiClient: url = "{}/auth/request".format(self.PRINTER_API_PREFIX) request_body = json.dumps({ "application": CuraApplication.getInstance().getApplicationDisplayName(), - "user": username, + "user": f"user@{platform.node()}", }).encode("utf-8") reply = self._manager.post(self.createEmptyRequest(url, method=HttpRequestMethod.POST, skip_auth=True), request_body) From a8ac8e93324cda60a36191657474a7a730a2e540 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 9 Sep 2025 09:27:23 +0200 Subject: [PATCH 8/8] Forgot to update this (needed for authentication). part of CURA-12624 --- plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index bbe7932d66..25617ce824 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -125,7 +125,7 @@ class ClusterApiClient: """Delete a print job from the queue.""" url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) - self._manager.deleteResource(self.createEmptyRequest(url)) + self._manager.deleteResource(self.createEmptyRequest(url, method=HttpRequestMethod.DELETE)) def setPrintJobState(self, print_job_uuid: str, state: str) -> None: """Set the state of a print job."""