Просмотр исходного кода

fix(vp): resolve hostname/FQDN targets in MQTT bridge IP encoding (#1429 follow-up)

  Printers added to Bambuddy by hostname/FQDN (e.g. p1s.fritz.box) hit
  'invalid IPv4' in _ip_to_uint32_le, so the net.info[*].ip rewrite never
  armed and BambuStudio Send went straight to the real printer instead of
  the Bambuddy archive whenever the printer was powered on.

  Add _resolve_target_to_ipv4(target): IPv4 pass-through, else
  socket.getaddrinfo(target, family=AF_INET). AF_INET filter is load-bearing
  because net.info[*].ip is uint32 LE and IPv6 can't round-trip. OSError
  returns None so a transient DNS failure recovers on the next 30s refresh
  tick via the existing not-armed throttle.

  Apply the resolver to both the encode call and the host-interface picker
  (which also assumes dotted-quad). Armed log line now carries
  configured->resolved when they differ, so bad-DNS regressions stay legible
  in 'docker logs'. The unresolvable not-armed reason now names the
  configured value rather than parroting 'invalid IPv4', distinguishing
  'DNS gave a v6 result' from 'user typed garbage'.

  Root-caused by @Mape6; @TrickShotMLG02 confirmed the FQDN workaround
  on the same release. Pre-0.2.4 these setups worked by accident because
  there was no net.info[].ip rewrite at all.
maziggy 3 месяцев назад
Родитель
Сommit
aed01f875a

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 48 - 3
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -35,8 +35,10 @@ from __future__ import annotations
 
 import asyncio
 import copy
+import ipaddress
 import json
 import logging
+import socket
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
@@ -90,6 +92,38 @@ def _ip_to_uint32_le(ip_str: str) -> int:
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
 
 
+def _resolve_target_to_ipv4(target: str) -> str | None:
+    """Return a dotted-quad IPv4 for `target`, resolving hostnames if needed.
+
+    The printer client may be configured by IPv4 *or* by hostname/FQDN
+    (e.g. `p1s.fritz.box`) — the latter is common on home LANs with a
+    DNS-providing router. The downstream `net.info[].ip` field is a
+    32-bit little-endian integer though, so a hostname can't round-trip
+    through it; we have to pick *one* concrete IPv4 to write in.
+
+    Returns None if `target` is empty, not parseable as IPv4, and DNS
+    resolution fails — caller logs that as the not-armed reason and
+    re-tries on the next refresh tick (DHCP/DNS churn picks itself up).
+    """
+    if not target:
+        return None
+    try:
+        return str(ipaddress.IPv4Address(target))
+    except (ValueError, ipaddress.AddressValueError):
+        pass
+    try:
+        # AF_INET filters to IPv4 only; the rewrite field is uint32 LE,
+        # there's no IPv6 representation that fits.
+        infos = socket.getaddrinfo(target, None, family=socket.AF_INET)
+    except OSError:
+        return None
+    for info in infos:
+        sockaddr = info[4]
+        if sockaddr and isinstance(sockaddr[0], str):
+            return sockaddr[0]
+    return None
+
+
 def _resolve_host_interface_for_target(target_ip: str) -> str | None:
     """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
 
@@ -411,11 +445,21 @@ class MQTTBridge:
             _log_not_armed("target_client is None (bridge not bound to a printer)")
             return
 
-        target_ip = getattr(client, "ip_address", None)
-        if not target_ip:
+        configured_target = getattr(client, "ip_address", None)
+        if not configured_target:
             _log_not_armed("printer client has no ip_address yet")
             return
 
+        # Printers configured by hostname/FQDN (e.g. `p1s.fritz.box`) need to
+        # be resolved to an IPv4 before encoding: net.info[*].ip is uint32 LE
+        # and can't carry a hostname (#1429 follow-up).
+        target_ip = _resolve_target_to_ipv4(configured_target)
+        if not target_ip:
+            _log_not_armed(
+                f"could not resolve printer host {configured_target!r} to IPv4 (invalid address and DNS lookup failed)"
+            )
+            return
+
         vp_ip = getattr(self._mqtt_server, "bind_address", None)
         vp_ip_source = "bind_address"
         if not vp_ip or vp_ip in ("0.0.0.0", ""):  # nosec B104
@@ -446,11 +490,12 @@ class MQTTBridge:
         self._vp_ip_uint32_le = new_vp_le
         # Clear the dedup so a future failure re-emits the diagnostic line.
         self._not_armed_reason = None
+        target_display = target_ip if target_ip == configured_target else f"{configured_target}→{target_ip}"
         logger.info(
             "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
             self.vp_name,
             "updated" if was_armed else "armed",
-            target_ip,
+            target_display,
             vp_ip,
             vp_ip_source,
         )

+ 70 - 3
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -3,6 +3,7 @@
 import asyncio
 import json
 import logging
+import socket
 from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 
@@ -12,6 +13,7 @@ from backend.app.services.virtual_printer.mqtt_bridge import (
     MQTTBridge,
     _ip_to_uint32_le,
     _resolve_host_interface_for_target,
+    _resolve_target_to_ipv4,
 )
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 
@@ -1088,6 +1090,62 @@ class TestIpEncoding:
             _ip_to_uint32_le("not.an.ip.actually")
 
 
+class TestHostnameResolution:
+    """#1429 follow-up: users who configured the printer by FQDN (common on
+    LANs with router-provided DNS like `p1s.fritz.box`) hit `invalid IPv4`
+    on the encoder and the rewrite never armed — slicer kept FTPing direct
+    to the real printer. The bridge now resolves hostname→IPv4 first."""
+
+    def test_pass_through_for_valid_ipv4(self):
+        assert _resolve_target_to_ipv4("192.168.1.50") == "192.168.1.50"
+
+    def test_empty_returns_none(self):
+        assert _resolve_target_to_ipv4("") is None
+        assert _resolve_target_to_ipv4(None) is None  # type: ignore[arg-type]
+
+    def test_hostname_resolves_via_getaddrinfo(self):
+        with patch(
+            "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
+            return_value=[(2, 1, 6, "", ("192.168.3.153", 0))],
+        ) as mock_gai:
+            assert _resolve_target_to_ipv4("p1s.fritz.box") == "192.168.3.153"
+        # AF_INET filter prevents an IPv6-only result from being picked,
+        # since net.info[*].ip is a uint32 LE that can't carry v6.
+        assert mock_gai.call_args.kwargs.get("family") == socket.AF_INET
+
+    def test_dns_failure_returns_none(self):
+        with patch(
+            "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
+            side_effect=OSError("Name or service not known"),
+        ):
+            assert _resolve_target_to_ipv4("nope.invalid") is None
+
+    def test_fqdn_target_arms_encoding(self, caplog):
+        """End-to-end: a client whose `ip_address` is an FQDN should arm
+        the bridge once DNS resolves, and the cached rewrite uses the
+        resolved IPv4 (not the hostname string) for the `net.info[].ip`
+        encoding."""
+        server = _make_server(bind_address=VP_IP)
+        bridge = _make_bridge(server)
+        client = _make_paho_client(ip="p1s.fritz.box")
+        bridge._target_client = client
+        with (
+            patch(
+                "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
+                return_value=[(2, 1, 6, "", (H2D_IP, 0))],
+            ),
+            caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"),
+        ):
+            bridge._refresh_ip_encoding()
+        assert bridge._target_ip_uint32_le == _ip_to_uint32_le(H2D_IP)
+        assert bridge._vp_ip_uint32_le == _ip_to_uint32_le(VP_IP)
+        armed = [r for r in caplog.records if "MQTT bridge IP encoding armed" in r.getMessage()]
+        assert len(armed) == 1
+        # Operator should see configured→resolved in the log line so a
+        # bad-DNS regression is immediately legible.
+        assert "p1s.fritz.box→192.168.255.133" in armed[0].getMessage()
+
+
 # ---------------------------------------------------------------------------
 # Auto-resolve fallback for default-config (bind_address = "0.0.0.0")
 # ---------------------------------------------------------------------------
@@ -1236,17 +1294,26 @@ class TestNotArmedDiagnosticLogging:
         assert H2D_IP in msg
         assert "no host interface" in msg
 
-    def test_invalid_ipv4_logs_value_error(self, caplog):
+    def test_unresolvable_target_logs_reason(self, caplog):
+        """When `ip_address` isn't a valid IPv4 *and* doesn't resolve via DNS,
+        the bridge must report a single concrete not-armed reason naming the
+        configured value — operator can then see exactly what input failed."""
         server = _make_server(bind_address=VP_IP)
         bridge = _make_bridge(server)
         client = _make_paho_client()
         client.ip_address = "not.an.ip"
         bridge._target_client = client
-        with caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"):
+        with (
+            patch(
+                "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
+                side_effect=OSError("nodename nor servname provided"),
+            ),
+            caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"),
+        ):
             bridge._refresh_ip_encoding()
         not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
         assert len(not_armed) == 1
-        assert "invalid IPv4" in not_armed[0].getMessage()
+        assert "could not resolve printer host 'not.an.ip'" in not_armed[0].getMessage()
 
     def test_successful_arm_clears_dedup_so_future_failure_relogs(self, caplog):
         """After a successful arm, the dedup must reset so a subsequent

Некоторые файлы не были показаны из-за большого количества измененных файлов