refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational

The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
This commit is contained in:
maziggy
2026-05-03 14:58:29 +02:00
parent 556d666889
commit 64899a8ca4
23 changed files with 246 additions and 1377 deletions
@@ -340,106 +340,47 @@ class TestVirtualPrinterAutoDispatchAPI:
assert get_resp.json()["auto_dispatch"] is False
class TestVirtualPrinterTailscaleGuardAPI:
"""Enabling Tailscale on a host without the binary must be rejected with 409."""
class TestVirtualPrinterTailscaleToggleAPI:
"""The Tailscale toggle is informational — toggling either way always succeeds.
There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
back when the toggle controlled LE cert provisioning. That path was removed:
the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
system trust store, so even an LE cert wouldn't be accepted. The toggle now
only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
irrelevant to whether the toggle can be flipped.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_enable_tailscale_rejected_when_binary_missing(self, async_client: AsyncClient):
"""PUT tailscale_disabled=False must 409 when tailscale_service reports unavailable."""
from backend.app.services.virtual_printer.tailscale import TailscaleStatus
async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
"""PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestTailscaleGuard",
"name": "TestTailscaleToggle",
"mode": "immediate",
"access_code": "12345678",
},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
# New VPs default to tailscale_disabled=True (opt-in).
assert create_resp.json()["tailscale_disabled"] is True
mock_status = TailscaleStatus(
available=False,
hostname="",
tailnet_name="",
fqdn="",
error="tailscale binary not found",
)
with patch(
"backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
new=AsyncMock(return_value=mock_status),
new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
):
update_resp = await async_client.put(
enable_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"tailscale_disabled": False},
)
assert update_resp.status_code == 409
assert update_resp.json()["detail"] == "tailscale_not_available"
# Ensure the row was not modified.
get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
assert get_resp.json()["tailscale_disabled"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_enable_tailscale_allowed_when_binary_present(self, async_client: AsyncClient):
"""PUT tailscale_disabled=False succeeds when tailscale_service reports available."""
from backend.app.services.virtual_printer.tailscale import TailscaleStatus
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestTailscaleAllow",
"mode": "immediate",
"access_code": "12345678",
},
)
vp_id = create_resp.json()["id"]
mock_status = TailscaleStatus(
available=True,
hostname="host",
tailnet_name="tail.ts.net",
fqdn="host.tail.ts.net",
error=None,
)
with patch(
"backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
new=AsyncMock(return_value=mock_status),
):
update_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"tailscale_disabled": False},
)
assert update_resp.status_code == 200
assert update_resp.json()["tailscale_disabled"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_disable_tailscale_skips_binary_check(self, async_client: AsyncClient):
"""Disabling (tailscale_disabled=True) never calls tailscale_service — always allowed."""
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestTailscaleDisableSkip",
"mode": "immediate",
"access_code": "12345678",
},
)
vp_id = create_resp.json()["id"]
# get_status must not be called on the disable path.
with patch(
"backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
new=AsyncMock(side_effect=AssertionError("get_status must not be called for disable")),
):
update_resp = await async_client.put(
disable_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"tailscale_disabled": True},
)
assert update_resp.status_code == 200
assert update_resp.json()["tailscale_disabled"] is True
assert enable_resp.status_code == 200
assert enable_resp.json()["tailscale_disabled"] is False
assert disable_resp.status_code == 200
assert disable_resp.json()["tailscale_disabled"] is True