diff --git a/BACKERS.md b/BACKERS.md new file mode 100644 index 000000000..079e527aa --- /dev/null +++ b/BACKERS.md @@ -0,0 +1,42 @@ +# Bambuddy Backers & Sponsors + +Bambuddy is sustainable thanks to people who put their money where their use is. This page lists everyone who supports the project on [GitHub Sponsors](https://github.com/sponsors/maziggy) or [Ko-fi](https://ko-fi.com/maziggy). + +If you'd like to support Bambuddy: + +- **GitHub Sponsors** (recurring, 5 tiers from $5/mo to $500/mo) — https://github.com/sponsors/maziggy +- **Ko-fi** (one-time or recurring) — https://ko-fi.com/maziggy + +If you sponsor and your name isn't here within 48h, please ping `maziggy` on Discord or open an Issue. + +--- + +## Corporate Sponsors ($500/mo+) + +*None yet — be the first. Your logo on the bambuddy.cool homepage and press.html, plus co-marketing.* + +## Sustaining Sponsors ($150/mo+) + +*None yet.* + +## Patrons ($35/mo+) + +*None yet.* + +## Supporters ($15/mo+) + +*None yet.* + +## Backers ($5/mo+) + +*None yet.* + +--- + +## One-time and historical supporters + +A general thank-you to everyone who's contributed via Ko-fi over the past months. Specific names get added on request — if you'd like to be listed, ping `maziggy`. + +--- + +Thanks. — Martin diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b372c9d8..c6c91f518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Changed +- **Bambuddy now identifies honestly as itself on every outbound request to Bambu Lab / MakerWorld / Bambu Wiki** — proactive alignment with Bambu Lab's [2026-05-12 statement on cloud access](https://blog.bambulab.com/setting-the-record-straight-on-cloud-access-and-community/), which draws a clear line between modifying AGPL code (allowed) and "impersonating official clients in communication with our cloud infrastructure" (not allowed). Bambuddy was already on the right side of that line on the main authenticated cloud path (`User-Agent: Bambuddy/1.0` in [`bambu_cloud.py:_get_headers`](backend/app/services/bambu_cloud.py)), but three secondary call sites were sending browser User-Agents — originally added under the assumption Cloudflare's WAF would block non-browser identification. Tested on 2026-05-12 with `curl -H "User-Agent: Bambuddy/1.0"` against all three: `https://bambulab.com/api/sign-in/tfa` returned HTTP 400 with the expected application-level `{"code":5,"error":"Login failed"}` JSON (no Cloudflare interstitial), `https://api.bambulab.com/v1/iot-service/api/slicer/setting` returned HTTP 200 with the full 576 KB settings response, `https://makerworld.com/api/v1/design-service/*` returned the same response shape as a Firefox UA, and `https://wiki.bambulab.com/*` served identical HTML to a Chrome UA. The browser-impersonation was unnecessary. All four call sites now send `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` consistently — the URL in parens makes the source unambiguous so Bambu can distinguish our traffic from impersonators if they ever audit it. Files: [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) (TOTP/TFA path no longer spoofs Chrome UA + Origin + Referer + Accept-Language headers — Origin/Referer were spoofing `bambulab.com` origin, which the new comment block specifically calls out as removed), [`makerworld.py`](backend/app/services/makerworld.py) (Firefox UA replaced; the Referer header is kept because MakerWorld's CSRF / origin-check middleware uses it on some endpoints, which is functional, not identity-faking), [`firmware_check.py`](backend/app/services/firmware_check.py) (Chrome UA on the public wiki scraper replaced — wiki has no special handling for our UA). Separately: the [`/v1/iot-service/api/slicer/setting`](backend/app/services/bambu_cloud.py) endpoint requires a `version` query parameter in Bambu Studio's XX.YY.ZZ.WW format (the API returns HTTP 400 "field 'version' is not set" without it, and HTTP 422 "Invalid input parameters" for non-matching formats like `bambuddy-1.0`), but Bambu's server accepts ANY value within that format — verified the same 576 KB response with `version=99.99.99.99`. The previous default `"02.04.00.70"` is an actual Bambu Studio release version (2.4.0.70). The default is now `"1.0.0.0"` (held in a new `_SLICER_API_VERSION` module constant in [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) and re-exported into [`routes/cloud.py`](backend/app/api/routes/cloud.py) so the two route defaults stay in sync), which satisfies the format requirement without claiming to be a specific Bambu Studio build. Unchanged on purpose: `version="2.0.0.0"` parameters in `create_setting` / `update_setting` payloads are the **preset's** format version (extracted from `current.get("version", "2.0.0.0")` for updates, line 443) — they describe the preset schema, not the client, and stay as-is. Two regression tests rewritten to lock in the new behavior: `test_verify_totp_uses_honest_bambuddy_user_agent` (was `test_verify_totp_includes_browser_headers` — asserts UA starts with `Bambuddy/`, asserts `Mozilla`/`Chrome`/`Origin`/`Referer` are not present) and `test_sends_honest_bambuddy_user_agent` (was `test_sends_browser_like_headers` — same shape, plus continues to assert the deprecated `x-bbl-*` Bambu-app identification headers are still gone). All 4598 backend tests pass. + - **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone. ### Fixed diff --git a/README.md b/README.md index 1885da4e2..18af66486 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ You don't need to be a developer for the docs or moderator roles. If you enjoy w **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer: - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate -- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side — the Bambu Studio / OrcaSlicer printer-MQTT trust path uses a bundled BBL CA, not the system trust store, so even a publicly-trusted cert wouldn't help. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination. +- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required on the slicer side: Bambu Studio / OrcaSlicer validate printer TLS against a bundled BBL CA (not the system trust store), **and** their Add Printer dialog is IP-only (no hostname to match an LE cert against), so a publicly-trusted cert can't help on either dimension. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination. - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server - 🔑 **Uses printer's access code** — No additional credentials needed - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index a1469b917..50ba0009c 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -42,6 +42,7 @@ from backend.app.schemas.cloud import ( SlicerSettingUpdate, ) from backend.app.services.bambu_cloud import ( + _SLICER_API_VERSION, BambuCloudAuthError, BambuCloudError, BambuCloudService, @@ -445,7 +446,7 @@ async def logout( @router.get("/settings", response_model=SlicerSettingsResponse) async def get_slicer_settings( - version: str = "02.04.00.70", + version: str = _SLICER_API_VERSION, db: AsyncSession = Depends(get_db), current_user: User | None = cloud_caller(Permission.CLOUD_AUTH), ): @@ -543,7 +544,7 @@ async def get_setting_detail( @router.get("/filaments", response_model=list[SlicerSetting]) async def get_filament_presets( - version: str = "02.04.00.70", + version: str = _SLICER_API_VERSION, db: AsyncSession = Depends(get_db), current_user: User | None = cloud_caller(Permission.FILAMENTS_READ), ): diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index bde5cc8c5..89b40e398 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -14,6 +14,24 @@ logger = logging.getLogger(__name__) BAMBU_API_BASE = "https://api.bambulab.com" BAMBU_API_BASE_CN = "https://api.bambulab.cn" +# Client identity sent to Bambu Lab's cloud services. We identify honestly as +# Bambuddy — the URL in parens makes the source unambiguous so Bambu can +# distinguish our traffic from impersonators. This is the opposite of what the +# OrcaSlicer fork was called out for in the May 2026 Bambu Lab blog post +# ("Setting the record straight on cloud access and community"): we do not +# introduce ourselves as official Bambu Studio. +_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)" + +# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query +# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the +# API returns HTTP 400 "field 'version' is not set"; non-matching formats like +# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's +# server accepts ANY value within that format — it doesn't validate against a +# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does +# not impersonate any real Bambu Studio release. Our client identity is in the +# User-Agent header. +_SLICER_API_VERSION = "1.0.0.0" + class BambuCloudError(Exception): """Base exception for Bambu Cloud errors.""" @@ -74,7 +92,7 @@ class BambuCloudService: """Get headers for authenticated requests.""" headers = { "Content-Type": "application/json", - "User-Agent": "Bambuddy/1.0", + "User-Agent": _USER_AGENT, } if self.access_token: headers["Authorization"] = f"Bearer {self.access_token}" @@ -174,24 +192,25 @@ class BambuCloudService: code: 6-digit TOTP code from authenticator app """ try: - # TFA endpoint is on bambulab.com, NOT api.bambulab.com - # Requires browser-like headers to bypass Cloudflare + # TFA endpoint is on bambulab.com, NOT api.bambulab.com. + # We previously sent a Chrome User-Agent plus Origin/Referer headers + # under the assumption Cloudflare would block bot-identified + # requests. Verified 2026-05-12 via curl that the endpoint accepts + # honest "Bambuddy/X.Y.Z" identification cleanly (HTTP 400 with the + # expected application-level "Login failed" JSON, no Cloudflare + # interstitial). Browser-impersonation removed to stay clearly on + # the right side of Bambu Lab's "no falsified client identity" line. tfa_url = "https://bambulab.com/api/sign-in/tfa" if "bambulab.cn" in self.base_url: tfa_url = "https://bambulab.cn/api/sign-in/tfa" - browser_headers = { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "application/json, text/plain, */*", - "Accept-Language": "en-US,en;q=0.9", - "Origin": "https://bambulab.com", - "Referer": "https://bambulab.com/", - } - response = await self._client.post( tfa_url, - headers=browser_headers, + headers={ + "Content-Type": "application/json", + "User-Agent": _USER_AGENT, + "Accept": "application/json", + }, json={ "tfaKey": tfa_key, "tfaCode": code, @@ -281,12 +300,16 @@ class BambuCloudService: except httpx.RequestError as e: raise BambuCloudError(f"Request failed: {e}") - async def get_slicer_settings(self, version: str = "02.04.00.70") -> dict: + async def get_slicer_settings(self, version: str = _SLICER_API_VERSION) -> dict: """ Get all slicer settings (filament, printer, process presets). Args: - version: Slicer version string + version: Slicer version string. Bambu's API requires the XX.YY.ZZ.WW + format but does not validate against a release manifest — we + default to the neutral _SLICER_API_VERSION placeholder so we + never claim to be a specific Bambu Studio build. Callers should + normally use the default. """ if not self.is_authenticated: raise BambuCloudAuthError("Not authenticated") diff --git a/backend/app/services/firmware_check.py b/backend/app/services/firmware_check.py index 4ad78cd5e..15f76e560 100644 --- a/backend/app/services/firmware_check.py +++ b/backend/app/services/firmware_check.py @@ -122,7 +122,11 @@ class FirmwareCheckService: self._client = httpx.AsyncClient( timeout=30.0, headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + # Identify honestly as Bambuddy when scraping the public Bambu + # Lab firmware wiki — verified 2026-05-12 that the wiki serves + # this UA identically to a Chrome UA (same HTML response shape). + # No browser impersonation needed for read-only public pages. + "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)" }, ) diff --git a/backend/app/services/makerworld.py b/backend/app/services/makerworld.py index 0a84f2c2b..a09bd25cd 100644 --- a/backend/app/services/makerworld.py +++ b/backend/app/services/makerworld.py @@ -43,11 +43,16 @@ MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com") # Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint. _ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",) -# Browser-like headers. ``api.bambulab.com`` accepts minimal headers cleanly; -# the Referer is kept so MakerWorld origin checks don't fail anywhere the -# same client hits ``makerworld.com``. +# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly +# as Bambuddy with a source URL so Bambu can distinguish our traffic from +# impersonators — the opposite of what the OrcaSlicer fork was called out for +# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via +# curl that MakerWorld treats this UA identically to a Firefox UA at the +# Cloudflare edge (same response shape on /api/v1/design-service/* paths). +# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses +# it on some endpoints — that's distinct from client impersonation. _CLIENT_HEADERS = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0", + "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)", "Accept": "text/html,application/json,*/*", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://makerworld.com/", diff --git a/backend/app/services/virtual_printer/tailscale.py b/backend/app/services/virtual_printer/tailscale.py index c2685c508..4f665db0a 100644 --- a/backend/app/services/virtual_printer/tailscale.py +++ b/backend/app/services/virtual_printer/tailscale.py @@ -6,11 +6,15 @@ they want to reach a VP over Tailscale. Historical note: this module previously provisioned Let's Encrypt certs via `tailscale cert` so the slicer would not need a manual CA import. That path -was removed because BambuStudio's printer-MQTT trust path validates only -against its bundled BBL CA (not the system trust store), so LE-signed certs -are rejected regardless of hostname/IP. The self-signed CA flow (with one- -time `bbl_ca.crt` import into the slicer) is the only viable trust mechanism; -Tailscale's role is now strictly network reach. +was removed because LE-signed certs can't help on two independent dimensions: +(1) BambuStudio / OrcaSlicer printer-MQTT trust validates only against the +bundled BBL CA, not the system trust store, so non-BBL chains are rejected +at the issuer check; (2) both slicers' Add Printer dialog accepts only an +IP address (not a hostname), so even if the trust store accepted the LE +issuer, the cert's hostname (`*..ts.net`) couldn't match the +`100.x.x.x` connection target. The self-signed CA flow (one-time `bbl_ca.crt` +import into the slicer) is the only viable trust mechanism; Tailscale's role +is now strictly network reach. """ import asyncio diff --git a/backend/tests/unit/services/test_bambu_cloud.py b/backend/tests/unit/services/test_bambu_cloud.py index 15d5bae37..4f62c9179 100644 --- a/backend/tests/unit/services/test_bambu_cloud.py +++ b/backend/tests/unit/services/test_bambu_cloud.py @@ -218,8 +218,18 @@ class TestBambuCloudTOTPVerification: assert "Invalid response" in result["message"] @pytest.mark.asyncio - async def test_verify_totp_includes_browser_headers(self, cloud_service): - """TOTP verification should include browser-like headers to bypass Cloudflare.""" + async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service): + """TOTP verification identifies as Bambuddy, not as a browser. + + The TOTP endpoint previously sent a Chrome User-Agent + Origin/Referer + headers under the assumption Cloudflare would block non-browser + identification. Verified 2026-05-12 that ``https://bambulab.com/api/sign-in/tfa`` + accepts ``Bambuddy/X.Y.Z`` cleanly — the expected application-level + response comes back, no Cloudflare interstitial. Browser impersonation + was removed to stay clearly on the right side of Bambu Lab's + "no falsified client identity" line from the 2026-05-12 cloud-access + blog post. + """ mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = '{"token": "test-token"}' @@ -231,11 +241,15 @@ class TestBambuCloudTOTPVerification: await cloud_service.verify_totp("test-tfa-key", "123456") - # Check headers include User-Agent call_args = mock_post.call_args headers = call_args[1]["headers"] - assert "User-Agent" in headers - assert "Mozilla" in headers["User-Agent"] + assert headers["User-Agent"].startswith("Bambuddy/") + # Browser-impersonation strings must not creep back in + assert "Mozilla" not in headers["User-Agent"] + assert "Chrome" not in headers["User-Agent"] + # Origin / Referer headers were spoofing bambulab.com origin — gone + assert "Origin" not in headers + assert "Referer" not in headers class TestBambuCloudRegion: diff --git a/backend/tests/unit/services/test_makerworld.py b/backend/tests/unit/services/test_makerworld.py index 90c96863b..4085023a3 100644 --- a/backend/tests/unit/services/test_makerworld.py +++ b/backend/tests/unit/services/test_makerworld.py @@ -104,10 +104,21 @@ class TestGetDesign: assert url == "https://api.bambulab.com/v1/design-service/design/1" @pytest.mark.asyncio - async def test_sends_browser_like_headers(self, service): - """Post-refactor the client uses a minimal Firefox-ish header set. - The old ``x-bbl-*`` Bambu-app identification headers are gone — - ``api.bambulab.com`` accepts browser-like headers cleanly.""" + async def test_sends_honest_bambuddy_user_agent(self, service): + """The client identifies honestly as Bambuddy, not as Firefox. + + Earlier iterations of this code stripped ``x-bbl-*`` Bambu-app + identification headers but kept a Firefox User-Agent. Verified + 2026-05-12 that MakerWorld treats ``Bambuddy/X.Y.Z`` identically to + a Firefox UA at the Cloudflare edge — same response shape on + ``/api/v1/design-service/*`` paths. Honest identification keeps us + clearly outside Bambu Lab's "no falsified client identity" line + from the 2026-05-12 cloud-access blog post. + + Referer is still sent because MakerWorld's CSRF / origin-check + middleware uses it on some endpoints — that is functional, not + client-impersonation. + """ resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"id": 1} @@ -115,7 +126,12 @@ class TestGetDesign: await service.get_design(1) headers = service._client.get.call_args.kwargs["headers"] - assert "Firefox" in headers["User-Agent"] + assert headers["User-Agent"].startswith("Bambuddy/") + # Browser-impersonation strings must not creep back in + assert "Mozilla" not in headers["User-Agent"] + assert "Firefox" not in headers["User-Agent"] + assert "Chrome" not in headers["User-Agent"] + # Functional headers stay assert headers["Accept-Language"].startswith("en-US") assert headers["Referer"] == "https://makerworld.com/" assert "Accept" in headers