mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
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:
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user