fix(virtual-printer): give each install's CA a name of its own (issue #3014)

A slicer holding the CA of two Bambuddy installs could only connect to
    one of them. Each CA worked on its own; together, one stopped, with the
    generic "Connect ... failed! [SN:..., code=-1]" that an install whose
    CA was never imported gives.

    Every install signed as exactly CN=Virtual Printer CA. A slicer's trust
    store is a flat list of certificates and OpenSSL resolves an issuer by
    Subject DN: it takes the first authority whose name matches and fails
    the chain when that one turns out not to have signed the certificate,
    rather than trying the next match. Whichever CA landed second in the
    file lost -- decided by nothing but the order they were appended in.
    Reproduced with openssl verify against a bundle holding two CAs: the
    first leaf verifies, the second fails with "certificate signature
    failure".

    - certificate.py: a newly generated CA takes a suffix from its own key
      identifier (CN=Virtual Printer CA D55808BE) and publishes that
      identifier, which the printer certificate points back at.
    - Existing CAs are untouched, so nothing has to be re-imported. A
      printer certificate signed by one keeps exactly the shape it has
      today: the authority key identifier is added only when the CA has an
      identifier to name.
    - tests: unique names per install, the identifier reaching the leaf,
      an existing CA being reused unchanged, and both chains verifying
      through openssl from a single trust store.

    The collision goes away as soon as one of the two CAs is newer than
    this change. Two installs that both predate it still collide until one
    has its bbl_ca.crt/.key deleted and regenerated, which is a re-import
    for that one -- documented in the wiki.
This commit is contained in:
maziggy
2026-09-24 14:52:37 +02:00
3 changed files with 184 additions and 12 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
### Fixed
- **Two Bambuddy instances could not have their CA certificates trusted at the same time (#3014, reported by @Steven-Pierce)** — a slicer holding the CA of two Bambuddy installs failed to connect to one of them, with the same generic `code=-1` an install whose CA was never imported gives. Each CA worked on its own; together, one stopped. Every install signed its virtual printers with an authority named exactly `CN=Virtual Printer CA`, and a slicer's trust store is a flat list of certificates that OpenSSL resolves by subject name: it takes the first authority whose name matches the certificate in front of it and fails the chain when that authority turns out not to have signed it, rather than trying the next match. Whichever of the two landed second in the file therefore lost, decided by nothing more than the order they were appended in. A newly generated CA now carries a per-install suffix taken from its own key — `CN=Virtual Printer CA D55808BE` — and publishes that key identifier, which the printer certificate then points back at, so the two are told apart by name and each chain resolves to the authority that really signed it. Nothing has to be re-imported on upgrade: an install that already has a CA keeps it untouched, and the printer certificates signed by it keep exactly the shape they have today. The collision goes away as soon as one of the two CAs is newer than this release — a second install set up after upgrading, or an existing one whose `bbl_ca.crt` and `bbl_ca.key` are deleted from `virtual_printer/certs/` so a fresh CA is generated, which does mean importing that one into the slicer again.
- **ntfy per-event priorities were set, stored, and never sent (#3139, reported by @Thomansky)** — a per-event priority (#990) never reached ntfy: every notification arrived at the server's default, whatever the dialog showed. The two halves of the feature disagreed about the key. The dialog builds its rows from the provider's event toggles and stores the map under those names — `on_print_complete`, `on_print_failed` — while every sender is called with the bare event name, `print_complete`, so the lookup missed for all 18 events the dialog offers. It looked healthy from both ends: the stored config held exactly the priorities that were set, and the tests were green because they called the ntfy sender directly with the prefixed name, the one spelling the running system never produces. The lookup now accepts either spelling, so existing configurations keep working and nothing has to be migrated. The tests use the bare form, and one of them now runs from a finished print through to the outgoing request, which is the assertion that would have caught this. On upgrade, priorities that have been sitting in the database inertly start being honoured, so an event mapped to **Min** or **Low** will now arrive quieter than it did yesterday — that is the setting taking effect, not alerts going missing. The daily digest is unchanged and still goes out at the server default: it summarises several events at once, and the dialog offers no priority for it.
- **Restoring a backup into a different version of Bambuddy failed, and the failure took the live database with it** — A backup carries the schema of the install that made it, and the PostgreSQL restore path does not use that schema: it drops every table, recreates them from the running process's ORM, and inserts the backup's columns into the result. Any NOT NULL column the running version has and the backup does not then arrives with nothing to put in it, and a model-side `default=` cannot help, because SQLAlchemy applies one to ORM and Core inserts and never to the raw `text()` SQL this import builds — `create_all` emits no DDL default for it either. A backup made after `user_wallets.currency` was dropped in #3123, restored onto an install that still had the column, died on `null value in column "currency"` with the drop already committed: the restore's first transaction wipes the database, so the install was left with an empty schema, the previous data gone, and the MFA key file already replaced by the backup's — whatever had survived would have been encrypted under a key that no longer matched. The import now fills such a column from the model's default (evaluating a callable one), leaves a column with a server default to the database rather than second-guessing a timestamp it is better placed to produce, and ignores a nullable one exactly as before. A column that genuinely cannot be filled is refused **before anything is touched** — before services are paused, before the key file is written, before the first `DROP` — and the same applies to a `bambuddy.db` that is not a readable SQLite database at all, which previously was discovered only by the import that had already dropped the tables. Backups now carry a `manifest.json` naming the version that wrote them, so the refusal reads "this backup was made by X, this install runs Y" rather than a list of column names; a backup taken before the manifest existed restores exactly as it did, with the version simply unknown. The refusal is an HTTP 400, and the frontend was dropping its body on the floor — a refused restore is an `HTTPException`, so its `{detail}` is not the `{success, message}` a successful one returns, and the modal raised an empty error toast — so the reason now reaches the operator who has to act on it. A SQLite install is unaffected throughout: it restores by copying the backup's pages, schema included, and migrates forward afterwards.
- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server.
@@ -1,7 +1,8 @@
"""TLS certificate generation for virtual printer services.
Generates certificates that mimic real Bambu printer certificate format:
- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
Generates the certificate chain a slicer accepts in place of a real printer's:
- CA certificate with CN = "Virtual Printer CA <id>", unique to the install
that generated it (a CA generated before that carries the bare name)
- Printer certificate has CN = serial number, signed by the CA
The CA certificate is persistent and only regenerated if missing or expired.
@@ -27,6 +28,11 @@ DEFAULT_SERIAL = "00M09A391800001"
# Minimum days remaining before CA is considered expired and needs regeneration
CA_EXPIRY_THRESHOLD_DAYS = 30
# Common-name prefix of the generated CA. What follows it is derived from the
# CA's own public key, so two installs never share a Subject DN -- see
# ``_generate_ca_certificate`` for why that matters.
CA_COMMON_NAME_PREFIX = "Virtual Printer CA"
def _get_local_ip() -> str:
"""Get the local IP address."""
@@ -43,8 +49,10 @@ def _get_local_ip() -> str:
class CertificateService:
"""Generate and manage TLS certificates for virtual printer.
Creates a certificate chain mimicking real Bambu printers:
- Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
Creates a certificate chain a slicer accepts in place of a real
printer's:
- Root CA with CN="Virtual Printer CA <id>", unique to the install that
generated it (an older CA carries the bare name and is kept as it is)
- Printer cert with CN=serial_number, signed by the CA
"""
@@ -90,9 +98,10 @@ class CertificateService:
is broken even though both files exist on disk. ``ensure_certificates``
uses this to decide whether to regenerate.
Uses real signature verification — Bambuddy's auto-generated CAs all
share the same Subject DN ("Virtual Printer CA"), so a DN-only compare
would incorrectly return True even after rotation.
Uses real signature verification — every CA generated before the
common name carried a per-install suffix is literally
"CN=Virtual Printer CA", so on those installs a DN-only compare would
incorrectly return True even after rotation.
"""
try:
if not self.ca_cert_path.exists():
@@ -213,10 +222,25 @@ class CertificateService:
key_size=2048,
)
# Use a generic CA name - NOT BBL to avoid being rejected as fake
# Use a generic CA name - NOT BBL to avoid being rejected as fake.
#
# The name carries a per-install suffix taken from this CA's own key
# identifier. A slicer trust store is a flat list of certificates and
# OpenSSL looks an issuer up by Subject DN: it takes the first CA whose
# DN matches and fails the chain if that one did not sign the
# certificate, rather than trying the next match. So while every
# install signed as plain "CN=Virtual Printer CA", a user who imported
# the CAs of two Bambuddy instances broke one of them — each worked on
# its own, together whichever landed second in the file lost, with the
# same generic connection error an unimported CA gives (#3014).
# Distinct DNs mean both are found and both verify.
ca_skid = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
ca_name = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
x509.NameAttribute(
NameOID.COMMON_NAME,
f"{CA_COMMON_NAME_PREFIX} {ca_skid.digest.hex()[:8].upper()}",
),
]
)
@@ -248,6 +272,7 @@ class CertificateService:
),
critical=True,
)
.add_extension(ca_skid, critical=False)
.sign(ca_key, hashes.SHA256())
)
@@ -313,12 +338,23 @@ class CertificateService:
# Issuer is the CA
issuer = ca_cert.subject
# Key identifiers, but only when the CA carries one to point at. A CA
# generated before the per-install common name has no
# SubjectKeyIdentifier, and a leaf signed by it keeps exactly the shape
# it has today rather than naming an identifier its issuer does not
# advertise — those installs keep working with the CA they imported
# long ago, untouched.
try:
ca_skid = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
except x509.ExtensionNotFound:
ca_skid = None
now = datetime.now(timezone.utc)
local_ip = _get_local_ip()
logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
# Build printer certificate signed by CA
printer_cert = (
printer_cert_builder = (
x509.CertificateBuilder()
.subject_name(printer_subject)
.issuer_name(issuer)
@@ -357,9 +393,19 @@ class CertificateService:
),
critical=True,
)
.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
)
if ca_skid is not None:
printer_cert_builder = printer_cert_builder.add_extension(
x509.SubjectKeyIdentifier.from_public_key(printer_key.public_key()),
critical=False,
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_skid),
critical=False,
)
printer_cert = printer_cert_builder.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
# Write printer private key
self.key_path.write_bytes(
printer_key.private_bytes(
@@ -380,7 +426,7 @@ class CertificateService:
self.cert_path.write_bytes(cert_chain)
logger.info("Generated certificate chain at %s", self.cert_dir)
logger.info(" CA: CN=Virtual Printer CA")
logger.info(" CA: %s", ca_cert.subject.rfc4514_string())
logger.info(" Printer: CN=%s", self.serial)
return self.cert_path, self.key_path
@@ -5,6 +5,7 @@ Tests the virtual printer manager, FTP server, and SSDP server components.
import asyncio
import json
import shutil
import zipfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
@@ -3774,6 +3775,130 @@ class TestCertificateService:
assert cert_path.exists()
assert key_path.exists()
# --- CA identity (#3014) ------------------------------------------------
#
# A slicer trust store is a flat list of certificates, and OpenSSL resolves
# an issuer by Subject DN: the first CA whose DN matches is the only one
# tried. While every Bambuddy install signed as plain "Virtual Printer CA",
# importing the CAs of two instances broke whichever landed second in the
# file — with the same generic connection error an unimported CA produces.
@staticmethod
def _install(base, serial):
"""Build a CertificateService laid out the way the manager lays one out."""
from backend.app.services.virtual_printer.certificate import CertificateService
return CertificateService(cert_dir=base / "0", serial=serial, shared_ca_dir=base)
@staticmethod
def _plant_legacy_ca(ca_dir):
"""Write a pre-#3014 CA: plain common name, no SubjectKeyIdentifier."""
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA")])
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + timedelta(days=7300))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.sign(key, hashes.SHA256())
)
ca_dir.mkdir(parents=True, exist_ok=True)
(ca_dir / "bbl_ca.key").write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
(ca_dir / "bbl_ca.crt").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return cert
def test_ca_common_name_is_unique_per_install(self, tmp_path):
"""Two installs must not produce CAs that share a Subject DN."""
from cryptography import x509
from backend.app.services.virtual_printer.certificate import CA_COMMON_NAME_PREFIX
cas = []
for name in ("a", "b"):
service = self._install(tmp_path / name, "TEST123")
service.generate_certificates()
cas.append(x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes()))
subjects = [ca.subject.rfc4514_string() for ca in cas]
assert all(s.startswith(f"CN={CA_COMMON_NAME_PREFIX} ") for s in subjects)
assert subjects[0] != subjects[1]
def test_ca_key_identifier_is_carried_into_the_printer_certificate(self, cert_service):
"""The CA advertises a key id and the leaf names it as its authority."""
from cryptography import x509
cert_path, _ = cert_service.generate_certificates()
leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
ca = x509.load_pem_x509_certificate(cert_service.ca_cert_path.read_bytes())
ca_skid = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
leaf_akid = leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
assert leaf_akid.key_identifier == ca_skid.digest
def test_existing_ca_is_reused_and_its_certificate_shape_is_unchanged(self, tmp_path):
"""An install that already has a CA keeps it — nothing to re-import.
Its printer certificate also stays exactly as it was: no authority key
identifier, because the CA it was signed by advertises none.
"""
from cryptography import x509
from cryptography.hazmat.primitives import hashes
planted = self._plant_legacy_ca(tmp_path)
service = self._install(tmp_path, "TEST123")
cert_path, _ = service.generate_certificates()
ca = x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes())
assert ca.fingerprint(hashes.SHA256()) == planted.fingerprint(hashes.SHA256())
assert ca.subject.rfc4514_string() == "CN=Virtual Printer CA"
leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
with pytest.raises(x509.ExtensionNotFound):
leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
@pytest.mark.skipif(shutil.which("openssl") is None, reason="needs the openssl binary")
def test_two_installs_verify_from_a_single_trust_store(self, tmp_path):
"""The reported symptom: both CAs imported, both chains must verify."""
import subprocess
leaves = []
bundle = b""
for name, serial in (("a", "00M09A391800001"), ("b", "01P00A391800002")):
service = self._install(tmp_path / name, serial)
cert_path, _ = service.generate_certificates()
# The per-VP file is a chain (leaf + CA); OpenSSL reads the leaf first.
leaves.append(cert_path)
bundle += service.ca_cert_path.read_bytes()
bundle_path = tmp_path / "trust_store.pem"
bundle_path.write_bytes(bundle)
for leaf in leaves:
result = subprocess.run(
["openssl", "verify", "-CAfile", str(bundle_path), str(leaf)],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
class TestBindServer:
"""Tests for BindServer (port 3002 bind/detect protocol)."""