Files
bambuddy/backend/app/services/virtual_printer/diagnostic.py
maziggy 28a6ca6f4d Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.

332a7c6ac added the line to install/install.sh in March under the heading
"Fix install.sh missing AmbientCapabilities". Three other places define the
same unit and none of them got it: the manual template, the combined
Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
The wiki additionally claimed the capability was always included.

Also diagnose it. The VP diagnostic reported only that nothing was listening
on 990, which reads identically to a port conflict. It now checks CapEff for
the capability and names it as the cause -- but stays quiet when the port is
answering (an iptables REDIRECT is the documented alternative and that host
works) and when the capability is held (the port is down for another reason
and blaming this would misdirect). Skips where there is no procfs rather
than putting a systemd instruction in front of a macOS user.
2026-08-04 08:33:30 +02:00

252 lines
11 KiB
Python

"""Setup diagnostic for a virtual printer.
A virtual printer fails for the user in ways a real printer never does: the
bind IP no longer exists after a host/network change, a service silently
failed to bind its port, the access code was never set, the slicer was never
told to trust the CA. The manager swallows per-service start errors
(``run_with_logging`` in ``start_server``), so a service object can exist
while nothing is actually listening — the only reliable signal is probing the
bind IP's ports from the outside, which is what this does.
Each check carries a stable ``id`` and a ``status`` of pass / fail / warn /
skip; the frontend renders the localized title and fix text keyed on that
id + status.
"""
import asyncio
import logging
import os
from backend.app.models.virtual_printer import VirtualPrinter
from backend.app.schemas.printer import DiagnosticCheck
from backend.app.schemas.virtual_printer import VPDiagnosticResult
logger = logging.getLogger(__name__)
# Server-mode listening ports — see virtual_printer/manager.py start_server().
PORT_FTPS = 990 # implicit FTPS — slicer file upload
PORT_MQTT = 8883 # MQTT over TLS — control + status
PORT_BIND = 3002 # bind/detect (TLS) — slicer discovery handshake
PORT_BIND_PLAIN = 3000 # bind/detect (plain) — legacy / some slicer models
_PORT_PROBE_TIMEOUT = 2.0
# Linux capability number for CAP_NET_BIND_SERVICE (linux/capability.h).
_CAP_NET_BIND_SERVICE = 10
def can_bind_privileged_ports() -> bool | None:
"""Whether this process is allowed to bind ports below 1024.
Returns ``None`` when that cannot be determined — no procfs to read and not
running as root, i.e. macOS or Windows, where this capability model does not
apply and the caller should skip the check rather than guess.
Reading the effective set covers both ways the permission is granted,
because both are visible at runtime: ``AmbientCapabilities`` in the systemd
unit (or ``cap_add: [NET_BIND_SERVICE]`` in Docker), and
``setcap cap_net_bind_service=+ep`` on the interpreter binary.
Note this answers "does the process hold the capability", not "can port 990
be bound" — a host with ``net.ipv4.ip_unprivileged_port_start`` lowered can
bind it without holding anything. Callers must treat a False here as a
*possible* explanation for a port that failed to open, never as proof on its
own; the caller in this module only reports it when a probe actually failed.
"""
geteuid = getattr(os, "geteuid", None)
if geteuid is not None and geteuid() == 0:
return True
try:
with open("/proc/self/status", encoding="utf-8") as fh:
for line in fh:
if line.startswith("CapEff:"):
caps = int(line.split(":", 1)[1].strip(), 16)
return bool((caps >> _CAP_NET_BIND_SERVICE) & 1)
except (OSError, ValueError):
return None
return None
async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
"""Test TCP connectivity to ip:port. Returns True if something is listening."""
try:
_reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
"""Run setup checks for a virtual printer.
Args:
vp: The virtual printer DB row.
instance: The running ``VirtualPrinterInstance`` from the manager, or
``None`` if the VP is not currently instantiated.
"""
checks: list[DiagnosticCheck] = []
is_proxy = vp.mode == "proxy"
running = bool(instance and instance.is_running)
# --- VP enabled ---
checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
# --- Instance running ---
if not vp.enabled:
checks.append(DiagnosticCheck(id="running", status="skip"))
else:
checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
# --- Bind interface still exists ---
# A bind IP picked weeks ago can vanish after a Docker restart or a router
# handing out a different lease — the VP then binds nothing and is invisible.
if not vp.bind_ip:
checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
else:
from backend.app.services.network_utils import find_interface_for_ip
iface = find_interface_for_ip(vp.bind_ip)
checks.append(
DiagnosticCheck(
id="bind_interface",
status="pass" if iface else "fail",
params={"bind_ip": vp.bind_ip},
)
)
# --- Access code (non-proxy modes only) ---
if is_proxy:
checks.append(DiagnosticCheck(id="access_code", status="skip"))
else:
checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
# --- Target printer (proxy mode only) ---
if not is_proxy:
checks.append(DiagnosticCheck(id="target_printer", status="skip"))
elif not vp.target_printer_id:
checks.append(DiagnosticCheck(id="target_printer", status="fail"))
else:
from backend.app.services.printer_manager import printer_manager
state = printer_manager.get_status(vp.target_printer_id)
online = bool(state and state.connected)
# A configured-but-offline target degrades proxying but isn't a setup
# error on the VP's side — warn rather than fail.
checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
# --- Service ports actually listening on the bind IP ---
# The decisive check: a service object can exist while its socket never
# bound (port already in use, permission denied) because start errors are
# logged and swallowed. Probe the bind IP directly.
bind_ip = vp.bind_ip
ftp_ok: bool | None = None
if not running or not bind_ip:
for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
elif is_proxy:
# Proxy mode listens on dynamic ports reported by the proxy manager,
# and runs no bind/detect server.
proxy_status = instance.get_status().get("proxy", {})
ftp_port = proxy_status.get("ftp_port")
mqtt_port = proxy_status.get("mqtt_port")
ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
checks.append(
DiagnosticCheck(
id="port_ftps",
status="pass" if ftp_ok else "fail",
params={"port": ftp_port or PORT_FTPS},
)
)
checks.append(
DiagnosticCheck(
id="port_mqtt",
status="pass" if mqtt_ok else "fail",
params={"port": mqtt_port or PORT_MQTT},
)
)
checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
else:
# The non-proxy bind server listens on BOTH 3000 (plain) and 3002
# (TLS) per bind_server.py BIND_PORTS — slicers pick either path.
# Probing only 3002 missed half-dead VPs where one listener failed
# to start and the other succeeded; report port_bind as pass only
# when both probes succeed.
ftp_ok, mqtt_ok, bind_tls_ok, bind_plain_ok = await asyncio.gather(
_check_port(bind_ip, PORT_FTPS),
_check_port(bind_ip, PORT_MQTT),
_check_port(bind_ip, PORT_BIND),
_check_port(bind_ip, PORT_BIND_PLAIN),
)
checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
checks.append(
DiagnosticCheck(
id="port_bind",
status="pass" if (bind_tls_ok and bind_plain_ok) else "fail",
params={"port": PORT_BIND, "port_plain": PORT_BIND_PLAIN},
)
)
# --- Privileged port binding ---
# 990 (FTPS) and 322 (RTSP) are below 1024, so a service running as a normal
# user cannot bind them without CAP_NET_BIND_SERVICE. When it is missing the
# sockets never open, and every symptom above is a downstream effect: the
# slicer simply never sees the printer. The EACCES is logged by TCPProxy but
# that is one line in the journal, and the port checks alone report the same
# "nothing is listening" as an ordinary port conflict — which is what sent
# the reporter in #2549 to Discord for several days over one missing line in
# a unit file.
#
# Reported only when a privileged port actually failed to answer. The
# capability can legitimately be absent on a host that fronts these ports
# some other way (an iptables REDIRECT is the documented alternative), and
# flagging a working setup would be noise.
if not running or ftp_ok is None:
checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
else:
has_cap = can_bind_privileged_ports()
if has_cap is None:
# No procfs to read and not obviously root — typically macOS or
# Windows, where this whole capability model does not apply.
checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
else:
checks.append(
DiagnosticCheck(
id="privileged_ports",
status="pass" if (has_cap or ftp_ok) else "fail",
params={"port": PORT_FTPS},
)
)
# --- TLS certificate ---
# When running, the cert chain must exist on disk for the slicer's TLS
# handshake to succeed. This is a pass/fail on the file; the localized
# detail text reminds the user to import the CA into the slicer.
if not running:
checks.append(DiagnosticCheck(id="certificate", status="skip"))
else:
cert_ok = bool(instance and instance.cert_path.exists())
checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
statuses = {c.status for c in checks}
if "fail" in statuses:
overall = "problems"
elif "warn" in statuses:
overall = "warnings"
else:
overall = "ok"
return VPDiagnosticResult(
vp_id=vp.id,
vp_name=vp.name,
mode=vp.mode,
overall=overall,
checks=checks,
)