mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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.
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""Unit tests for TailscaleService — presence detection only.
|
|
|
|
Cert provisioning was removed: BambuStudio's printer-MQTT trust path validates
|
|
against its bundled BBL CA, not the system trust store, so a Tailscale-issued
|
|
LE cert was rejected regardless of hostname/IP. The Tailscale toggle is now
|
|
informational (surfacing the host's Tailscale IP/FQDN to guide the user).
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestTailscaleService:
|
|
"""Tests for TailscaleService CLI wrapper — get_status only."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_binary_not_found(self):
|
|
"""Returns available=False when the tailscale binary is absent from PATH."""
|
|
from backend.app.services.virtual_printer.tailscale import TailscaleService
|
|
|
|
svc = TailscaleService()
|
|
with patch("shutil.which", return_value=None):
|
|
status = await svc.get_status()
|
|
|
|
assert status.available is False
|
|
assert status.error is not None
|
|
assert "not found" in status.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_command_fails(self):
|
|
"""Returns available=False when `tailscale status` exits non-zero."""
|
|
from backend.app.services.virtual_printer.tailscale import TailscaleService
|
|
|
|
svc = TailscaleService()
|
|
with (
|
|
patch("shutil.which", return_value="/usr/bin/tailscale"),
|
|
patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"permission denied")),
|
|
):
|
|
status = await svc.get_status()
|
|
|
|
assert status.available is False
|
|
assert "permission denied" in (status.error or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_success(self):
|
|
"""Parses FQDN, hostname, tailnet_name, and IP list from JSON output."""
|
|
from backend.app.services.virtual_printer.tailscale import TailscaleService
|
|
|
|
payload = {
|
|
"Self": {
|
|
"DNSName": "myhost.example.ts.net.",
|
|
"TailscaleIPs": ["100.1.2.3", "fd7a::1"],
|
|
}
|
|
}
|
|
svc = TailscaleService()
|
|
with (
|
|
patch("shutil.which", return_value="/usr/bin/tailscale"),
|
|
patch.object(
|
|
svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
|
|
),
|
|
):
|
|
status = await svc.get_status()
|
|
|
|
assert status.available is True
|
|
assert status.fqdn == "myhost.example.ts.net"
|
|
assert status.hostname == "myhost"
|
|
assert status.tailnet_name == "example.ts.net"
|
|
assert "100.1.2.3" in status.tailscale_ips
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_empty_dnsname(self):
|
|
"""Returns available=False when Tailscale daemon reports no DNSName (not connected)."""
|
|
from backend.app.services.virtual_printer.tailscale import TailscaleService
|
|
|
|
payload = {"Self": {"DNSName": "", "TailscaleIPs": []}}
|
|
svc = TailscaleService()
|
|
with (
|
|
patch("shutil.which", return_value="/usr/bin/tailscale"),
|
|
patch.object(
|
|
svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
|
|
),
|
|
):
|
|
status = await svc.get_status()
|
|
|
|
assert status.available is False
|
|
assert "no DNSName" in (status.error or "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_malformed_json(self):
|
|
"""Returns available=False with a parse-error reason when stdout is not JSON."""
|
|
from backend.app.services.virtual_printer.tailscale import TailscaleService
|
|
|
|
svc = TailscaleService()
|
|
with (
|
|
patch("shutil.which", return_value="/usr/bin/tailscale"),
|
|
patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not-json{", b"")),
|
|
):
|
|
status = await svc.get_status()
|
|
|
|
assert status.available is False
|
|
assert "JSON parse error" in (status.error or "")
|