mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...) leak through. Bambu Studio's Send pre-flight read them as busy and refused. The cached branch now overrides the activity-field set the same way it already overrode storage indicators (#1228) and protocol fields. Same bundle ships a multi-round VP audit that found adjacent bugs in the same family: - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload) - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status - _pending_files cleanup in finally for archive / queue / dispatch handlers - _add_to_print_queue position uses MAX+1 (was hardcoded 1) - DELETE VP removes orphan PendingUpload rows + upload_dir from disk - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match) - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map) - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation) - FTP passive port range widened 50000-51000; docker-compose + wiki updated - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
202 lines
7 KiB
Python
202 lines
7 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 LE-signed certs can't help on two independent dimensions:
|
|
(1) BambuStudio / OrcaSlicer printer-MQTT trust validates only against the
|
|
bundled BBL CA, not the system trust store, so non-BBL chains are rejected
|
|
at the issuer check; (2) both slicers' Add Printer dialog accepts only an
|
|
IP address (not a hostname), so even if the trust store accepted the LE
|
|
issuer, the cert's hostname (`*.<tailnet>.ts.net`) couldn't match the
|
|
`100.x.x.x` connection target. The self-signed CA flow (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, asyncio.TimeoutError) as e:
|
|
# asyncio.TimeoutError covers the case where ``_run_tailscale``
|
|
# killed a stuck subprocess and re-raised. Without this branch
|
|
# the timeout escaped into the FastAPI route handler and could
|
|
# crash the VP management UI for users with a lagging
|
|
# tailscaled daemon.
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=str(e) or "tailscale status timed out",
|
|
)
|
|
|
|
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()
|