mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +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.
194 lines
6.3 KiB
Python
194 lines
6.3 KiB
Python
"""Tailscale presence detection for virtual printers.
|
|
|
|
Reports whether tailscaled is reachable and surfaces the host's Tailscale IPs
|
|
and FQDN so the UI can show users which IP to paste into the slicer when
|
|
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.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Minimal environment for tailscale subprocess — passes OS/shell variables that
|
|
# tailscale needs to locate its socket and config, but strips application secrets
|
|
# (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for.
|
|
_SUBPROCESS_ENV: dict[str, str] = {
|
|
k: v
|
|
for k, v in os.environ.items()
|
|
if k
|
|
in {
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"USERNAME",
|
|
"LOGNAME",
|
|
# Windows equivalents
|
|
"USERPROFILE",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"PROGRAMFILES",
|
|
"PROGRAMFILES(X86)",
|
|
"SYSTEMROOT",
|
|
"WINDIR",
|
|
"COMPUTERNAME",
|
|
"TEMP",
|
|
"TMP",
|
|
# Linux XDG dirs used by tailscale for socket/config
|
|
"XDG_RUNTIME_DIR",
|
|
"XDG_CONFIG_HOME",
|
|
}
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TailscaleStatus:
|
|
"""Runtime Tailscale availability and identity."""
|
|
|
|
available: bool
|
|
hostname: str # "myhost"
|
|
tailnet_name: str # "tailnetname.ts.net"
|
|
fqdn: str # "myhost.tailnetname.ts.net"
|
|
tailscale_ips: list[str] = field(default_factory=list)
|
|
error: str | None = None
|
|
|
|
|
|
class TailscaleService:
|
|
"""Wraps `tailscale status` for presence detection.
|
|
|
|
All methods are safe to call when Tailscale is absent — they return
|
|
sensible defaults and never raise exceptions.
|
|
"""
|
|
|
|
_docker_hint_logged: bool = False
|
|
|
|
@classmethod
|
|
def _log_docker_socket_hint(cls) -> None:
|
|
"""Log a one-time hint when running in Docker without the Tailscale socket mounted."""
|
|
if cls._docker_hint_logged:
|
|
return
|
|
if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
|
|
logger.info(
|
|
"Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
|
|
"Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` "
|
|
"to docker-compose.yml (under volumes:) and run Tailscale on the host to "
|
|
"expose virtual printers over your tailnet."
|
|
)
|
|
cls._docker_hint_logged = True
|
|
|
|
async def _run_tailscale(self, *args: str, timeout: float = 30.0) -> tuple[int | None, bytes, bytes]:
|
|
"""Run a tailscale subcommand and return (returncode, stdout, stderr).
|
|
|
|
Resolves the binary to an absolute path to guard against PATH hijacking.
|
|
"""
|
|
binary = shutil.which("tailscale")
|
|
if not binary:
|
|
raise OSError("tailscale binary not found")
|
|
process = await asyncio.create_subprocess_exec(
|
|
binary,
|
|
*args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_SUBPROCESS_ENV,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
process.kill()
|
|
await process.wait()
|
|
raise
|
|
return process.returncode, stdout, stderr
|
|
|
|
async def get_status(self) -> TailscaleStatus:
|
|
"""Query Tailscale status and return machine identity.
|
|
|
|
Returns TailscaleStatus(available=False) if the binary is missing,
|
|
the daemon is not running, or any other error occurs.
|
|
"""
|
|
if not shutil.which("tailscale"):
|
|
self._log_docker_socket_hint()
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error="tailscale binary not found",
|
|
)
|
|
|
|
try:
|
|
returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
|
|
except OSError as e:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=str(e),
|
|
)
|
|
|
|
if returncode is None or returncode != 0:
|
|
self._log_docker_socket_hint()
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=stderr.decode(errors="replace").strip(),
|
|
)
|
|
|
|
try:
|
|
data = json.loads(stdout)
|
|
except json.JSONDecodeError as e:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=f"JSON parse error: {e}",
|
|
)
|
|
|
|
self_info = data.get("Self", {})
|
|
|
|
# DNSName includes trailing dot: "myhost.tailnetname.ts.net."
|
|
fqdn = self_info.get("DNSName", "").rstrip(".")
|
|
if not fqdn:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error="Tailscale not connected (no DNSName)",
|
|
)
|
|
|
|
parts = fqdn.split(".", 1)
|
|
hostname = parts[0]
|
|
tailnet_name = parts[1] if len(parts) > 1 else ""
|
|
|
|
tailscale_ips = self_info.get("TailscaleIPs", [])
|
|
|
|
logger.debug("Tailscale available: fqdn=%s, ips=%s", fqdn, tailscale_ips)
|
|
return TailscaleStatus(
|
|
available=True,
|
|
hostname=hostname,
|
|
tailnet_name=tailnet_name,
|
|
fqdn=fqdn,
|
|
tailscale_ips=tailscale_ips,
|
|
)
|
|
|
|
|
|
# Module-level singleton — import this in other modules
|
|
tailscale_service = TailscaleService()
|