Feature: MQTT Fallbacks + Alternative Auth#29
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the cloud integration layer to better match observed Bambu cloud API behavior, adds a cloud fallback for firmware data when MQTT hasn’t populated versions yet, and persists the account-level “beta firmware opt-in” flag across restarts. It also introduces initial (not yet wired) support for fetching mTLS device certificates for cloud MQTT.
Changes:
- Switch
get_user_print_infoto use/v1/iot-service/api/user/print?force=trueand adjust field mapping logic accordingly. - Add cloud firmware fallback in
bambu_network_get_printer_firmware, including beta firmware filtering controlled bysetting.isFirmwareBetaOpenpersisted viaauth::Store. - Add
cloud::fetch_device_cert()and document the mTLS certificate retrieval endpoint/shape; add unit tests for auth store persistence/back-compat.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
tests/auth_persist_test.cpp |
New hermetic tests for auth::Store JSON persistence, including legacy-file behavior. |
src/cloud_auth.cpp |
Parse refreshExpiresIn, plumb isFirmwareBetaOpen, and add fetch_device_cert() REST call. |
src/bind_cloud.cpp |
Adjust printer rename payload field to dev_name. |
src/auth.cpp |
Persist/load firmware_beta_open and add update_firmware_beta() API. |
src/agent.cpp |
Add has_firmware_data() and persist beta flag after profile fetch. |
src/abi_http.cpp |
Update print-info endpoint and add cloud firmware fallback + beta filtering. |
NETWORK_PLUGIN.md |
Document mTLS auth endpoint and confirm ticket/profile/firmware response shapes + headers. |
include/obn/cloud_auth.hpp |
Extend auth/profile structs and add DeviceCertResult + fetch_device_cert() decl. |
include/obn/auth.hpp |
Add firmware_beta_open to session and store API to update it. |
include/obn/agent.hpp |
Declare Agent::has_firmware_data(). |
CMakeLists.txt |
Add auth_persist_test target and CTest registration. |
Comments suppressed due to low confidence (2)
src/abi_http.cpp:260
- The beta-filter logic only filters dev.firmware entries and ignores dev.ams[].firmware entries from the cloud response. If the user hasn’t opted into beta firmware, AMS beta entries (or all AMS entries if you rebuild the object) may still leak through or be dropped inconsistently. Apply the same filtering to all firmware arrays (printer + AMS) while keeping the rest of each device object intact.
out << "{\"dev_id\":" << obn::json::escape(dev.find("dev_id").as_string());
out << ",\"firmware\":[";
auto fw_v = dev.find("firmware");
const auto& fw = fw_v.as_array();
bool first_fw = true;
for (const auto& entry : fw) {
if (entry.find("status").as_string() == "beta") continue;
if (!first_fw) out << ',';
first_fw = false;
out << entry.dump();
}
src/abi_http.cpp:170
- This switches get_user_print_info to /user/print?force=true. Per the repo’s own NETWORK_PLUGIN.md notes, this endpoint can return a success envelope with no "devices" key (e.g. when the queue is empty). Currently that maps to an empty device list, which would make Studio show no devices. Consider detecting a missing/empty devices array and falling back to /user/bind (or otherwise preserving the previous device list) instead of returning an empty list.
const std::string url = obn::cloud::api_host(a->cloud_region())
+ "/v1/iot-service/api/user/print?force=true";
std::map<std::string, std::string> hdrs{
{"Authorization", "Bearer " + s.access_token},
};
auto resp = obn::http::get_json(url, hdrs);
if (http_code) *http_code = static_cast<unsigned int>(resp.status_code);
if (!resp.error.empty()) {
OBN_WARN("get_user_print_info: transport: %s", resp.error.c_str());
if (http_body) *http_body = resp.body;
return BAMBU_NETWORK_ERR_GET_USER_PRINTINFO_FAILED;
}
if (resp.status_code != 200) {
OBN_WARN("get_user_print_info: HTTP %ld body=%s",
resp.status_code, resp.body.c_str());
if (http_body) *http_body = resp.body;
return BAMBU_NETWORK_ERR_GET_USER_PRINTINFO_FAILED;
}
std::vector<std::string> dev_ids;
std::string mapped = remap_bind_payload(resp.body, &dev_ids);
OBN_INFO("get_user_print_info: mapped %zu -> %zu bytes, %zu device(s)",
resp.body.size(), mapped.size(), dev_ids.size());
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Hi! I’m also afraid that many cloud-related functions will have to be either commented out or removed, since a lot of this is essentially dead code. Without a digital signature, we are still stuck in Developer Mode anyway, and this code could easily create legal issues. |
I've fixed them as suggested, it did have a couple good points :)
Yes, unfortunately I think you're right. I've left the code in for documentation purposes, however you're right that we're essentially locked out of it for now. The Since it touches the mTLS auth flow. Keeping it dead Side-note: All of these changes were made as an output of my full decomp, which is available here: |
| #### 6.3.1. Cloud MQTT authentication: mutual TLS (mTLS) | ||
|
|
||
| Cloud MQTT does **not** use username/password authentication. The stock plugin authenticates with client certificates obtained from the Bambu cloud REST API immediately before connecting to the broker. The following was confirmed via SSLKEYLOGFILE TLS decryption of the stock plugin's HTTPS traffic. | ||
|
|
||
| **Certificate retrieval endpoint:** | ||
|
|
||
| ``` | ||
| GET /v1/iot-service/api/user/applications/{application_token}/cert?aes256={base64url-key}&ver=1 | ||
| Authorization: Bearer {access_token} | ||
| ``` | ||
|
|
||
| `application_token` is an opaque token from the user's account (distinct from the MQTT bearer token). `aes256` is a base64url-encoded key used to decrypt the returned private key. `ver=1` is the API version. | ||
|
|
||
| **Response (200 OK):** | ||
|
|
||
| ```json | ||
| { | ||
| "message": "success", | ||
| "code": 0, | ||
| "error": null, | ||
| "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", | ||
| "crl": ["-----BEGIN X509 CRL-----\n...\n-----END X509 CRL-----\n"], | ||
| "key": "{base64-encoded-encrypted-private-key}" | ||
| } | ||
| ``` | ||
|
|
||
| The `cert` field contains a **3-certificate chain** (PEM-concatenated): | ||
|
|
||
| 1. **Device leaf cert** — subject: `GLOF{dev_id}-{hex}`, issuer: `GLOF{dev_id}.bambulab.com` | ||
| 2. **Per-device intermediate CA** — subject: `GLOF{dev_id}.bambulab.com`, issuer: `application_root.bambulab.com` | ||
| 3. **Root CA** — subject: `application_root.bambulab.com`, issuer: `BBL CA` | ||
|
|
||
| **Per-device CA architecture:** each device has its own intermediate CA named `{dev_uid}.bambulab.com`. Client certs are issued under this per-device CA, so the MQTT broker can verify which device a connection belongs to from the certificate alone. Revocation is per-device via the device-specific CRL returned in the `crl` field. Observed CRL validity: 30 days from issue date. | ||
|
|
||
| **`key` field:** the private key is AES-256 encrypted and base64-encoded. The `aes256` query parameter in the request URL is the decryption key. | ||
|
|
||
| **`crl` field:** a fresh CRL issued at request time, not a cached value. | ||
|
|
There was a problem hiding this comment.
Cloud MQTT does in fact use username/password authentication and the client only verifies the server's TLS certificate. It's not mTLS.
As said earlier, this is about authorization control and requires the built-in private key of the network plugin.
I have some insight from earlier versions but their implementation is a bit more complex and differs quite substantially from this current documentation. Please let me know whether it's a goal to document everything about it in more detail or if it's out of scope.
Here's also some related information:
open-bamboo-networking/NETWORK_PLUGIN.md
Lines 772 to 787 in d757bb3
Merge CMakeLists test targets from both branches, clarify cloud MQTT auth docs (open plugin token path vs stock mTLS), fall back from /user/print to /user/bind when the device list is empty, and filter beta firmware entries in ams[].firmware as well as dev.firmware. Co-authored-by: Cursor <cursoragent@cursor.com>
8a27de8 to
b5eb22e
Compare
| auth_store_->update_profile(prof.user_id, prof.user_name, | ||
| prof.nick_name, prof.avatar); | ||
| OBN_INFO("change_user: hello %s (uid=%s)", | ||
| auth_store_->update_firmware_beta(prof.firmware_beta_open); | ||
| OBN_INFO("change_user: hello %s (uid=%s, beta_fw=%d)", | ||
| prof.user_name.empty() ? prof.nick_name.c_str() : prof.user_name.c_str(), |
| // DeviceCertResult::key before the private key can be used with mosquitto. | ||
| DeviceCertResult fetch_device_cert(const std::string& region, | ||
| const std::string& access_token, | ||
| const std::string& application_token, | ||
| const std::string& aes256_key); |
5dbd9e2 to
1c4bb8e
Compare
While reverse engineering the stock plugin's traffic, I captured and decrypted the HTTPS sessions to map out which cloud endpoints it actually uses and what the responses look like.
A few of the existing stubs were pointed at the wrong endpoints or parsing fields by the wrong names. This PR fixes those, adds a cloud firmware fallback for when MQTT hasn't delivered version data yet, and wires the
isFirmwareBetaOpenaccount setting through so beta firmware entries are only shown to users who opted in. All changes were validated against the live API with a fresh bearer token.get_user_print_infofrom/v1/iot-service/api/user/bindto/v1/iot-service/api/user/print?force=true, which returns Studio-native field names directly (dev_name,dev_online,dev_access_code) and eliminates the translation layerget_printer_firmware: when no MQTTget_versiondata has arrived yet, hit/v1/iot-service/api/user/device/version?dev_id=with the requiredX-BBL-Client-IDheadersetting.isFirmwareBetaOpenis set in the user's profile; persist this flag throughauth::Storeso it survives restartsfetch_device_cert()infrastructure for the mTLS cert endpoint (/applications/{token}/cert) - endpoint and response shape fully confirmed, wiring to MQTT connect is a follow-on"beta"(the API does not use "BETA")crlfield parsing - the response wraps it in an array (["..."]), not a bare stringauth::Storepersistence coveringfirmware_beta_openround-trips and backwards compatibility with credential files written before the field existed