fix(diagnostics): read the subnet the host is actually on (issue #3092)

The Network subnet check told the reporter that 192.168.98.170 and
192.168.96.9 were on different networks and to go configure routing
between them. They are four hundred addresses apart inside one
192.168.96.0/22 LAN.

An IPv4 address does not carry its prefix, and the check supplied /24
for both sides. That is the most common LAN and not the only one, and
the guess is wrong in both directions: it splits a /22 and it merges a
/25. Read the prefix off the interface that owns the address instead.

find_local_ipv4_network() enumerates every interface, including the ones
EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends
out of the Virtual Printer's bind dropdown; here the caller is asking
about an address the kernel has already picked as a route source, and
answering "unknown" because it sits on a bridge would be a worse answer
than the truth. When nothing claims the address the check skips, which
is what it always did with no host IP at all -- it must not assert a
split it cannot see.

The same check chose which of Bambuddy's own addresses to compare by
probing a route toward 10.255.255.255, which on a multi-homed host is
not the interface the printer is on. It asks for the route toward the
printer now. On a two-NIC dev box that alone was warning about a printer
sitting on the second card's own subnet.

The probe takes IPv4 literals only. connect() on a name would resolve
it on the event loop, and _same_subnet rejects names anyway, so nothing
is lost. Resolving the prefix shells out to `ip -j addr show`, so it
moves off the loop too.

-----

fix(diagnostics): name the container engine instead of asking about Docker (issue #3092)

"Not running in Docker - not applicable", said to a Bambuddy inside a
Podman container. It reads as "you are on bare metal", and it sent the
reporter looking for his problem somewhere else.

Podman runs Bambuddy in exactly the two shapes Docker does, and the
shape is the thing that breaks printer discovery and the Virtual
Printer. detect_container_runtime() names the engine -- Docker, Podman,
Kubernetes, containerd, LXC, or a container it cannot place -- and the
check became Container network mode.

is_running_in_docker() is deliberately left alone rather than rewritten
on top of it. Three callers key real behaviour off that flag, and one of
them switches the Add Printer flow from SSDP to subnet scanning. SSDP
works for a host-networked Podman container, so answering True there
would take a working feature away to fix a sentence. Widening it is a
separate decision from naming the engine, so it is made separately.

Mode detection keeps the original signal first, which also makes the
Docker path incapable of regressing: a Docker host always has a docker0,
so a container that sees one shares its namespace, and the new rules can
only turn a warning into a pass. That signal says nothing about Podman,
which creates no such interface on a host running no bridge containers --
which is how host networking came to be reported as bridge. The general
form of the same idea answers for Podman: an interface whose iflink
equals its ifindex was created in this namespace, and a NAT-networked
container only ever receives one end of a veth pair. tun/tap is skipped,
because a container may run its own WireGuard and that tun is native to
a namespace it is not evidence of. The interface also has to be the one
the kernel just named -- sysfs is namespace-tagged but a bind-mounted
host /sys is not, and reading a colliding name's numbers would be
reading another namespace's answer.

What is still unreadable now says so and suggests host networking if
discovery is failing, rather than guessing bridge and telling a healthy
install to recreate itself. An LXC or LXD system container is named and
told the question does not apply: it is on the LAN like a small virtual
machine, so there is no network mode to recommend -- and its subnet
check still runs.

An engine we cannot name is a sentinel the frontend localizes, not a
word interpolated into thirteen other languages.

The support bundle carries the engine name beside the Docker flag, so
the next report of this shape is answerable from the bundle.
This commit is contained in:
maziggy
2026-09-19 12:19:19 +02:00
parent e4a9ef4550
commit 3a5f802cdc
27 changed files with 925 additions and 127 deletions
+6 -1
View File
@@ -33,7 +33,7 @@ from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.discovery import detect_container_runtime, is_running_in_docker
from backend.app.services.log_reader import (
LogEntry,
collect_sensitive_strings,
@@ -806,6 +806,11 @@ async def _collect_support_info() -> dict:
},
"environment": {
"docker": in_docker,
# Named separately from the Docker flag: a Podman or LXC bundle
# used to carry `"docker": false` and nothing else, which reads
# as bare metal and hid the deployment shape a report depended on
# (#3092).
"container_runtime": detect_container_runtime(),
"data_dir": _sanitize_path(str(settings.base_dir)),
"log_dir": _sanitize_path(str(settings.log_dir)),
"timezone": os.environ.get("TZ", ""),
+1 -1
View File
@@ -178,7 +178,7 @@ def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
Known values are matched first (longest first so "My Printer 1" beats
"My Printer"); the regex pass then catches any IPs the sensitive_strings
table didn't already cover — most importantly the Bambuddy host's own
IP (returned by ``_get_host_ip`` inside the diagnostic, not in the DB)
IP (returned by ``_host_source_ip`` inside the diagnostic, not in the DB)
and any virtual-printer ``bind_ip`` the user picked at setup.
"""
if not value:
+99 -1
View File
@@ -23,8 +23,106 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Runtime names :func:`detect_container_runtime` can return. These reach the
# user in the connection diagnostic, so they are the names people know their
# own setup by.
RUNTIME_DOCKER = "Docker"
RUNTIME_PODMAN = "Podman"
RUNTIME_KUBERNETES = "Kubernetes"
RUNTIME_CONTAINERD = "containerd"
RUNTIME_LXC = "LXC"
# Sentinel for "in a container we cannot name". The others are proper nouns
# that interpolate into any language; this one is localized by the frontend
# (diagnostic.check.network_mode.genericRuntime), so keep the two in step.
RUNTIME_OTHER = "container"
# Runtimes that put Bambuddy in an OCI container whose network mode is a
# choice the user made and can change. LXC is deliberately absent: a Proxmox
# or LXD system container is bridged onto the LAN like a small VM, so there is
# no "recreate it with host networking" advice to give.
OCI_RUNTIMES = frozenset({RUNTIME_DOCKER, RUNTIME_PODMAN, RUNTIME_KUBERNETES, RUNTIME_CONTAINERD, RUNTIME_OTHER})
# systemd writes the engine's own name here. It is the only signal that tells
# Podman apart from Docker without guessing, which is why it is consulted
# first (see updates.py, which has used it for the same reason for longer).
_SYSTEMD_CONTAINER = Path("/run/systemd/container")
_SYSTEMD_RUNTIME_NAMES = {
"docker": RUNTIME_DOCKER,
"podman": RUNTIME_PODMAN,
"containerd": RUNTIME_CONTAINERD,
"lxc": RUNTIME_LXC,
"lxc-libvirt": RUNTIME_LXC,
"oci": RUNTIME_OTHER,
}
def _read_text(path: Path) -> str:
"""Read a small /proc or /run marker file, empty string if unreadable."""
try:
return path.read_text()
except (OSError, ValueError):
# Unreadable, absent, or /proc entry that vanished mid-read.
return ""
def detect_container_runtime() -> str | None:
"""Name the container runtime Bambuddy is running under, or None.
``is_running_in_docker`` below answers a narrower question and is
deliberately left alone — see the comment on it.
Detection is ordered most-specific first, because the generic markers
cannot tell two engines apart: Podman sets ``/run/.containerenv`` *and*
writes ``libpod`` into the cgroup path, while Docker sets ``/.dockerenv``
and writes ``docker``. A container started by neither still gets a name
(``container``) rather than None, because "we are in something" is a
useful answer even when the engine is not.
"""
systemd_name = _read_text(_SYSTEMD_CONTAINER).strip().lower()
if systemd_name:
return _SYSTEMD_RUNTIME_NAMES.get(systemd_name, RUNTIME_OTHER)
# Podman writes /run/.containerenv into every container it starts. Older
# versions put it at the root, so both are checked.
if Path("/run/.containerenv").exists() or Path("/.containerenv").exists():
return RUNTIME_PODMAN
if Path("/.dockerenv").exists():
return RUNTIME_DOCKER
cgroup = _read_text(Path("/proc/1/cgroup"))
if "libpod" in cgroup:
return RUNTIME_PODMAN
if "kubepods" in cgroup:
return RUNTIME_KUBERNETES
if "docker" in cgroup:
return RUNTIME_DOCKER
if "containerd" in cgroup:
return RUNTIME_CONTAINERD
if "/lxc" in cgroup:
return RUNTIME_LXC
env_name = (os.environ.get("CONTAINER") or "").strip().lower()
if env_name:
return _SYSTEMD_RUNTIME_NAMES.get(env_name, RUNTIME_OTHER)
if os.environ.get("DOCKER_CONTAINER"):
return RUNTIME_DOCKER
return None
def is_running_in_docker() -> bool:
"""Detect if we're running inside a Docker container."""
"""Detect if we're running inside a Docker container.
Kept Docker-specific on purpose, and NOT rewritten on top of
:func:`detect_container_runtime`. Three callers key real behaviour off
this: ``/api/discovery/info`` feeds it to the Add-Printer flow, where
``isDocker`` switches discovery from SSDP to subnet scanning, and the
backup-path probe and support bundle both read it. Answering True for a
host-networked Podman container would take SSDP away from users for whom
it works (#3092). Widening it is a separate decision from naming the
runtime, so it is made separately.
"""
# Check for .dockerenv file
if Path("/.dockerenv").exists():
return True
+48 -10
View File
@@ -98,9 +98,15 @@ def _get_network_interfaces_psutil() -> list[dict]:
return interfaces
def get_network_interfaces() -> list[dict]:
def get_network_interfaces(include_excluded: bool = False) -> list[dict]:
"""Get all network interfaces with their IPs and subnets.
Args:
include_excluded: keep the interfaces ``EXCLUDED_INTERFACE_PREFIXES``
normally hides. That list exists to keep docker0 and friends out
of the Virtual Printer's bind dropdown; a caller asking about an
address the kernel has already chosen needs the real answer.
Returns:
List of dicts with name, ip, netmask, subnet, broadcast
"""
@@ -121,7 +127,7 @@ def get_network_interfaces() -> list[dict]:
name = iface[1]
# Skip excluded interfaces
if _is_excluded(name):
if not include_excluded and _is_excluded(name):
continue
try:
@@ -171,18 +177,21 @@ def get_network_interfaces() -> list[dict]:
return interfaces
def get_all_interface_ips() -> list[dict]:
"""Get all IPs (primary + aliases) for all non-excluded interfaces.
def get_all_interface_ips(include_excluded: bool = False) -> list[dict]:
"""Get all IPs (primary + aliases) for every interface, minus the excluded ones.
Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.
Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.
Args:
include_excluded: see :func:`get_network_interfaces`.
Returns:
List of dicts with name, ip, netmask, subnet, is_alias, label
"""
if not _IP_CMD:
logger.debug("ip command not found, using ioctl fallback")
return _fallback_get_all_ips()
return _fallback_get_all_ips(include_excluded)
try:
result = subprocess.run(
@@ -193,17 +202,17 @@ def get_all_interface_ips() -> list[dict]:
)
if result.returncode != 0:
logger.warning("ip addr show failed: %s", result.stderr)
return _fallback_get_all_ips()
return _fallback_get_all_ips(include_excluded)
interfaces_data = json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
logger.warning("Failed to run ip -j addr show: %s", e)
return _fallback_get_all_ips()
return _fallback_get_all_ips(include_excluded)
entries = []
for iface in interfaces_data:
ifname = iface.get("ifname", "")
if _is_excluded(ifname):
if not include_excluded and _is_excluded(ifname):
continue
ipv4_count = 0
@@ -241,7 +250,7 @@ def get_all_interface_ips() -> list[dict]:
return entries
def _fallback_get_all_ips() -> list[dict]:
def _fallback_get_all_ips(include_excluded: bool = False) -> list[dict]:
"""Fallback: wrap get_network_interfaces() result with alias fields."""
return [
{
@@ -249,10 +258,39 @@ def _fallback_get_all_ips() -> list[dict]:
"is_alias": False,
"label": iface["name"],
}
for iface in get_network_interfaces()
for iface in get_network_interfaces(include_excluded)
]
def find_local_ipv4_network(local_ip: str) -> ipaddress.IPv4Network | None:
"""The IPv4 network configured on the local interface holding ``local_ip``.
An IPv4 address carries no prefix length, so the only way to know how far
a LAN reaches is to read the prefix off the interface that owns the
address. ``None`` means no local interface claims it, which is the honest
answer whenever the platform gives us no interface data at all.
Nothing is filtered: ``local_ip`` is an address the kernel already picked
as a route source, so answering "unknown" because it happens to sit on a
bridge named ``br-something`` would be a worse answer than the truth.
"""
try:
address = ipaddress.IPv4Address(local_ip)
except ValueError:
return None
for iface in get_all_interface_ips(include_excluded=True):
if iface.get("ip") != str(address):
continue
try:
return ipaddress.IPv4Network(iface["subnet"], strict=False)
except (KeyError, TypeError, ValueError):
logger.debug("Interface %s has an unusable subnet %r", iface.get("name"), iface.get("subnet"))
return None
return None
def find_interface_for_ip(target_ip: str) -> dict | None:
"""Find which interface is on the same subnet as the target IP.
+136 -31
View File
@@ -2,7 +2,7 @@
Runs the checks a maintainer performs by hand when triaging a
"printer won't connect / won't print" report — port reachability, LAN
developer mode, Docker network mode, subnet match, and MQTT credentials —
developer mode, container network mode, subnet match, and MQTT credentials —
so users can self-diagnose setup problems instead of opening an issue.
See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
@@ -14,14 +14,16 @@ import ipaddress
import logging
import socket
import ssl
from pathlib import Path
from backend.app.models.printer import Printer
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
from backend.app.services.bambu_ftp import find_remote_file_async
from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
from backend.app.services.camera import get_camera_port
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.discovery import OCI_RUNTIMES, detect_container_runtime
from backend.app.services.ftp_profiles import get_ftp_profile
from backend.app.services.network_utils import find_local_ipv4_network
from backend.app.services.print_storage import (
REASON_INTERNAL_STORAGE,
StorageVerdict,
@@ -203,48 +205,135 @@ def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
return camera_port, "RTSPS"
def _detect_docker_network_mode() -> str:
"""Detect Docker network mode.
# Interfaces a container engine creates on the *host*. Seeing one of them
# means we are in the host's network namespace.
_HOST_INFRA_PREFIXES = ("docker", "br-", "veth", "virbr", "podman", "cni-", "cni_")
In host mode the container shares the host network namespace, so Docker
infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
mode the container only sees its own eth0.
def _has_native_interface() -> bool:
"""True if some interface here was created in this network namespace.
A NAT-networked container is handed one end of a veth pair per attached
network, and a veth's ``iflink`` points at its peer's index in the *other*
namespace, so it never equals its own ``ifindex``. An interface where the
two agree was made here — a physical NIC, a bridge, a VLAN — which a
container with its own namespace does not get.
tun/tap devices are skipped: a container can legitimately run its own
WireGuard or Tailscale client, and that tun would otherwise read as
evidence of a namespace it is not evidence of.
"""
try:
entries = [(idx, name) for idx, name in socket.if_nameindex() if name != "lo"]
except Exception:
return False
for index, name in entries:
# Never user input: the kernel's own interface table, and never a path.
iface = Path("/sys/class/net") / name # SEC-PATH-OK: name from socket.if_nameindex()
if (iface / "tun_flags").exists():
continue
try:
ifindex = (iface / "ifindex").read_text().strip()
iflink = (iface / "iflink").read_text().strip()
except (OSError, ValueError):
continue
# sysfs is tagged by network namespace, but a container given a bind
# mount of the host's /sys sees the host's interfaces under names that
# may collide with its own. Reading a different interface's numbers
# would be reading another namespace's answer, so require that the
# entry found here is the one the kernel just named.
if ifindex != str(index):
continue
if ifindex == iflink:
return True
return False
def _detect_container_network_mode(runtime: str | None) -> str | None:
"""Return "host", "bridge", or None when it genuinely cannot be told.
The first rule is the original Docker one and is kept exactly: a Docker
*host* always has a docker0, so a container that can see it shares the
host's namespace. It says nothing about Podman, which on a host running
no bridge containers creates no such interface at all — which is how a
host-networked Podman container came to be told it was on bridge
networking (#3092).
The second rule is the general form of the same idea and is what answers
for Podman. The third is the fallback the first rule always implied: an
OCI container that can see neither is isolated, which is what bridge
networking means.
"""
try:
for _idx, name in socket.if_nameindex():
if name.startswith(("docker", "br-", "veth", "virbr")):
if name.startswith(_HOST_INFRA_PREFIXES):
return "host"
except Exception:
pass
return "bridge"
if _has_native_interface():
return "host"
if runtime in OCI_RUNTIMES:
return "bridge"
return None
def _get_host_ip() -> str | None:
"""Best-effort IPv4 address the Bambuddy host routes from."""
def _host_source_ip(destination_ip: str) -> str | None:
"""The local IPv4 address Bambuddy would send from toward ``destination_ip``.
Asking about the printer's own address rather than a fixed far-away one
matters on any host with more than one NIC: the source for a route to the
internet is simply not the source for a route to the printer, and
comparing the printer against the wrong interface is a warning about
nothing (#3092).
Literals only. ``connect()`` on a name would resolve it, and this runs on
the event loop; ``_same_subnet`` rejects names anyway, so nothing is lost.
"""
try:
if ipaddress.ip_address(destination_ip).version != 4:
return None
except ValueError:
return None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# No packets are sent; this just picks the routing-table source IP.
s.connect(("10.255.255.255", 1))
s.connect((destination_ip, 1))
return s.getsockname()[0]
finally:
s.close()
except Exception:
# Fail soft: this is a diagnostic, and an unroutable address or an
# exhausted fd table must leave the check skipped, not 500 the page.
return None
def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
"""True/False if both are IPv4 literals in the same /24; None if undeterminable."""
def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
"""Is ``printer_ip`` inside the network configured on Bambuddy's ``host_ip``?
None means undeterminable — a name instead of an IPv4 literal, or no
local interface claiming ``host_ip``.
An address does not carry its prefix, and this used to supply ``/24`` for
both sides. That is the most common LAN and not the only one: on the
reporter's ``192.168.96.0/22`` it declared a printer four hundred
addresses away to be on a different network and told him to go configure
routing between two halves of one subnet (#3092). The prefix is read off
the interface that owns the source address instead.
"""
try:
addr_a = ipaddress.ip_address(ip_a)
addr_b = ipaddress.ip_address(ip_b)
printer_addr = ipaddress.ip_address(printer_ip)
host_addr = ipaddress.ip_address(host_ip)
except ValueError:
return None
if addr_a.version != 4 or addr_b.version != 4:
if printer_addr.version != 4 or host_addr.version != 4:
return None
net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
return net_a == net_b
network = find_local_ipv4_network(str(host_addr))
if network is None:
return None
return printer_addr in network
async def run_connection_diagnostic(
@@ -292,19 +381,34 @@ async def run_connection_diagnostic(
)
)
# --- Docker network mode ---
# --- Container network mode ---
# Not Docker-only: Podman runs Bambuddy in exactly the same two shapes and
# its users were told "Not running in Docker", which reads as "you are on
# bare metal" and sent them looking for the problem somewhere else (#3092).
runtime = detect_container_runtime()
network_mode: str | None = None
if is_running_in_docker():
network_mode = _detect_docker_network_mode()
if runtime is None:
checks.append(DiagnosticCheck(id="network_mode", status="skip"))
elif runtime not in OCI_RUNTIMES:
# An LXC/LXD system container is bridged onto the LAN like a small VM.
# There is no network mode to recommend, so don't imply there is one.
checks.append(
DiagnosticCheck(
id="network_mode",
status="pass" if network_mode == "host" else "warn",
params={"mode": network_mode},
)
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "system_container", "runtime": runtime})
)
else:
checks.append(DiagnosticCheck(id="network_mode", status="skip"))
network_mode = _detect_container_network_mode(runtime)
if network_mode is None:
checks.append(
DiagnosticCheck(id="network_mode", status="skip", params={"reason": "unknown", "runtime": runtime})
)
else:
checks.append(
DiagnosticCheck(
id="network_mode",
status="pass" if network_mode == "host" else "warn",
params={"mode": network_mode, "runtime": runtime},
)
)
# --- Subnet match ---
# Skipped in bridge mode: the container IP is the bridge IP, not the host's,
@@ -312,8 +416,9 @@ async def run_connection_diagnostic(
if network_mode == "bridge":
checks.append(DiagnosticCheck(id="subnet", status="skip"))
else:
host_ip = _get_host_ip()
same = _same_subnet(ip_address, host_ip) if host_ip else None
host_ip = _host_source_ip(ip_address)
# Off the loop: resolving the prefix shells out to `ip -j addr show`.
same = await asyncio.to_thread(_same_subnet, ip_address, host_ip) if host_ip else None
if same is None:
checks.append(DiagnosticCheck(id="subnet", status="skip"))
else:
@@ -0,0 +1,259 @@
"""Container detection for the connection diagnostic (#3092).
The reporter ran Bambuddy in a Podman container with host networking and was
told "Not running in Docker - not applicable", which reads as "you are on
bare metal" and sends people looking for the problem somewhere else. Two
separate questions are pinned here: which engine we are under, and whether
its network namespace is the host's.
"""
import builtins
import io
import os
from contextlib import contextmanager
from unittest.mock import patch
from backend.app.services import discovery
from backend.app.services.printer_diagnostic import (
_detect_container_network_mode,
_has_native_interface,
)
MOD = "backend.app.services.printer_diagnostic"
@contextmanager
def _host(files=None, env=None):
"""Present a fixed set of marker files and environment to the detector."""
files = files or {}
class _Path:
def __init__(self, p):
self._p = str(p)
def __str__(self):
return self._p
def exists(self):
return self._p in files
def _read(path):
return files.get(str(path), "")
real_open = builtins.open
def _open(path, *args, **kwargs):
# is_running_in_docker() reads /proc/1/cgroup with a plain open() and
# is deliberately left that way, so intercept only the paths under
# test and let everything else through untouched.
key = str(path)
if key in files:
return io.StringIO(files[key])
if key in ("/proc/1/cgroup", "/run/systemd/container"):
raise FileNotFoundError(key)
return real_open(path, *args, **kwargs)
with (
patch.object(discovery, "_read_text", _read),
patch.object(discovery, "Path", _Path),
patch.object(builtins, "open", _open),
patch.dict(os.environ, env or {}, clear=True),
):
yield
class TestDetectContainerRuntime:
def test_bare_metal_is_none(self):
with _host():
assert discovery.detect_container_runtime() is None
def test_podman_by_its_own_marker_file(self):
with _host({"/run/.containerenv": ""}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_PODMAN
def test_podman_by_the_older_root_marker(self):
with _host({"/.containerenv": ""}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_PODMAN
def test_podman_by_cgroup(self):
with _host({"/proc/1/cgroup": "0::/machine.slice/libpod-abc.scope\n"}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_PODMAN
def test_docker_by_its_own_marker_file(self):
with _host({"/.dockerenv": ""}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_DOCKER
def test_systemd_names_the_engine_and_wins(self):
"""The only signal that tells the two apart without guessing.
Podman leaves /.dockerenv alone, but a Docker-compatible shim may not,
so the file that carries the engine's own name is consulted first.
"""
with _host({"/run/systemd/container": "podman\n", "/.dockerenv": ""}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_PODMAN
def test_kubernetes(self):
with _host({"/proc/1/cgroup": "11:memory:/kubepods/besteffort/pod123\n"}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_KUBERNETES
def test_lxc_is_named_not_mistaken_for_docker(self):
with _host({"/run/systemd/container": "lxc\n"}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_LXC
def test_an_unnamed_engine_still_counts_as_a_container(self):
with _host({"/run/systemd/container": "some-new-engine\n"}):
assert discovery.detect_container_runtime() == discovery.RUNTIME_OTHER
def test_lxc_is_not_an_oci_runtime(self):
"""A system container is bridged onto the LAN like a small VM.
There is no "recreate it with host networking" advice to give, so it
must not fall into the branch that gives it.
"""
assert discovery.RUNTIME_LXC not in discovery.OCI_RUNTIMES
assert discovery.RUNTIME_PODMAN in discovery.OCI_RUNTIMES
class TestIsRunningInDockerIsUnchanged:
"""Naming Podman must not widen the flag three other callers key off.
/api/discovery/info feeds it to the Add-Printer flow, where isDocker
switches discovery from SSDP to subnet scanning. SSDP works for a
host-networked Podman container, so answering True there would take a
working feature away.
"""
def test_podman_does_not_read_as_docker(self):
with _host({"/run/.containerenv": "", "/proc/1/cgroup": "0::/machine.slice/libpod-abc.scope\n"}):
assert discovery.is_running_in_docker() is False
def test_docker_still_reads_as_docker(self):
with _host({"/.dockerenv": ""}):
assert discovery.is_running_in_docker() is True
def test_containerd_still_reads_as_docker(self):
with _host({"/proc/1/cgroup": "0::/system.slice/containerd.service\n"}):
assert discovery.is_running_in_docker() is True
def _sysfs(interfaces):
"""Present a fixed /sys/class/net to _has_native_interface().
``interfaces`` maps name -> (ifindex, iflink, is_tun). A veth's iflink is
its peer's index in another namespace, so the two never agree.
"""
class _Path:
def __init__(self, p):
self._p = str(p)
def __truediv__(self, other):
return _Path(f"{self._p}/{other}")
def _parts(self):
name, _, leaf = self._p.removeprefix("/sys/class/net/").partition("/")
return interfaces.get(name), leaf
def exists(self):
spec, leaf = self._parts()
return bool(spec) and leaf == "tun_flags" and spec[2]
def read_text(self):
spec, leaf = self._parts()
if not spec:
raise FileNotFoundError(self._p)
return f"{spec[0] if leaf == 'ifindex' else spec[1]}\n"
return (
patch(f"{MOD}.Path", _Path),
# The kernel names each interface with the same index sysfs reports,
# which is exactly what the cross-check below relies on.
patch(f"{MOD}.socket.if_nameindex", return_value=[(spec[0], name) for name, spec in interfaces.items()]),
)
@contextmanager
def _netns(interfaces):
path_patch, names_patch = _sysfs(interfaces)
with path_patch, names_patch:
yield
# A NAT-networked container: one veth per attached network, nothing else.
_BRIDGE_NETNS = {"lo": (1, 1, False), "eth0": (2, 45, False)}
# Host networking on a plain Linux box: a real NIC, native to this namespace.
_HOST_NETNS = {"lo": (1, 1, False), "enp3s0": (2, 2, False)}
class TestHasNativeInterface:
def test_a_natted_container_sees_only_veths(self):
with _netns(_BRIDGE_NETNS):
assert _has_native_interface() is False
def test_a_shared_host_namespace_has_a_real_nic(self):
with _netns(_HOST_NETNS):
assert _has_native_interface() is True
def test_a_bridge_counts(self):
"""A Proxmox/libvirt host may have nothing but vmbr0 with an address."""
with _netns({"lo": (1, 1, False), "vmbr0": (2, 2, False)}):
assert _has_native_interface() is True
def test_a_bind_mounted_host_sys_is_not_this_namespace(self):
"""sysfs is namespace-tagged, but a bind mount of the host's /sys is not.
A container given ``-v /sys:/sys`` sees the host's interfaces under
names that can collide with its own, and reading their numbers would
be reading another namespace's answer. The entry found in sysfs has
to be the one the kernel just named.
"""
interfaces = {"lo": (1, 1, False), "eth0": (2, 45, False)}
path_patch, _ = _sysfs({"lo": (1, 1, False), "eth0": (7, 7, False)})
with (
path_patch,
patch(f"{MOD}.socket.if_nameindex", return_value=[(i, n) for n, (i, _l, _t) in interfaces.items()]),
):
assert _has_native_interface() is False
def test_a_containers_own_vpn_does_not_count(self):
"""A container can run WireGuard or Tailscale; its tun is native here.
That says nothing about whose namespace this is, and counting it would
report host networking to a bridge-mode container.
"""
with _netns({"lo": (1, 1, False), "eth0": (2, 45, False), "wg0": (3, 3, True)}):
assert _has_native_interface() is False
class TestDetectContainerNetworkMode:
def test_docker_host_mode_by_the_original_signal(self):
"""A Docker host always has a docker0, whatever else is going on."""
with _netns({"lo": (1, 1, False), "eth0": (2, 45, False), "docker0": (3, 3, False)}):
assert _detect_container_network_mode(discovery.RUNTIME_DOCKER) == "host"
def test_docker_bridge_mode(self):
with _netns(_BRIDGE_NETNS):
assert _detect_container_network_mode(discovery.RUNTIME_DOCKER) == "bridge"
def test_podman_host_mode_on_a_host_with_no_engine_bridges(self):
"""#3092 itself.
A Podman host running no bridge containers creates no docker0, no
podman0 and no veth, so the original signal finds nothing and the old
code concluded bridge networking.
"""
with _netns(_HOST_NETNS):
assert _detect_container_network_mode(discovery.RUNTIME_PODMAN) == "host"
def test_podman_bridge_mode(self):
with _netns(_BRIDGE_NETNS):
assert _detect_container_network_mode(discovery.RUNTIME_PODMAN) == "bridge"
def test_podmans_own_bridge_is_a_host_signal_too(self):
with _netns({"lo": (1, 1, False), "eth0": (2, 45, False), "podman0": (3, 3, False)}):
assert _detect_container_network_mode(discovery.RUNTIME_PODMAN) == "host"
def test_an_isolated_namespace_under_no_known_engine_is_unknown(self):
"""Never guess bridge for something we cannot name — say so instead."""
with _netns(_BRIDGE_NETNS):
assert _detect_container_network_mode(None) is None
@@ -81,3 +81,50 @@ def test_psutil_path_filters_and_returns_bindable_ips():
assert "lo0" not in by_name # loopback filtered
assert "awdl0" not in by_name # link-local (169.254) filtered
assert "en5" not in by_name # interface down, skipped
_IP_ADDR_JSON = """[
{"ifname": "lo", "addr_info": [{"family": "inet", "local": "127.0.0.1", "prefixlen": 8}]},
{"ifname": "enp3s0", "addr_info": [{"family": "inet", "local": "192.168.96.9", "prefixlen": 22}]},
{"ifname": "enp4s0", "addr_info": [
{"family": "inet", "local": "10.0.0.5", "prefixlen": 24},
{"family": "inet", "local": "10.0.0.6", "prefixlen": 24, "label": "enp4s0:vp1"}
]},
{"ifname": "docker0", "addr_info": [{"family": "inet", "local": "172.17.0.1", "prefixlen": 16}]}
]"""
def _fake_ip_addr():
"""Patch `ip -j addr show` with a fixed multi-homed Linux host."""
result = namedtuple("CompletedProcess", ["returncode", "stdout", "stderr"])(0, _IP_ADDR_JSON, "")
return patch.object(network_utils, "subprocess", **{"run.return_value": result})
class TestFindLocalIPv4Network:
"""#3092: an address carries no prefix, so it has to be read off the interface."""
def test_reads_the_configured_prefix_not_a_guessed_24(self):
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("192.168.96.9")) == "192.168.96.0/22"
def test_an_alias_address_resolves_too(self):
# The VP binds aliases; an alias is a perfectly good route source.
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("10.0.0.6")) == "10.0.0.0/24"
def test_an_excluded_interface_still_answers(self):
"""EXCLUDED_INTERFACE_PREFIXES keeps docker0 out of the VP dropdown.
It must not also make the kernel's own choice of route source
unanswerable — "unknown" would be a worse answer than the truth.
"""
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert str(network_utils.find_local_ipv4_network("172.17.0.1")) == "172.17.0.0/16"
assert not [i for i in network_utils.get_all_interface_ips() if i["name"] == "docker0"]
def test_an_address_no_interface_holds_is_none(self):
with _fake_ip_addr(), patch.object(network_utils, "_IP_CMD", "/usr/sbin/ip"):
assert network_utils.find_local_ipv4_network("192.168.1.1") is None
def test_a_hostname_is_none(self):
assert network_utils.find_local_ipv4_network("printer.local") is None
@@ -5,6 +5,7 @@ drive the localized fix text the user sees when a printer won't connect,
so a status flip is a user-facing regression — each one is asserted here.
"""
import ipaddress
import ssl
import types
from contextlib import ExitStack
@@ -12,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.services.printer_diagnostic import (
_check_ftps_tls,
_host_source_ip,
_same_subnet,
run_connection_diagnostic,
)
@@ -24,6 +26,11 @@ def _statuses(result):
return {c.id: c.status for c in result.checks}
def _check(result, check_id):
"""The one check with this id — for asserting on its params, not just status."""
return next(c for c in result.checks if c.id == check_id)
def _port_probe(overrides=None):
"""Sync side_effect for _check_port. Defaults: every port reachable.
@@ -70,9 +77,10 @@ class _Env:
*,
ports=None,
ftps="ok",
in_docker=True,
runtime="Docker",
network_mode="host",
host_ip="192.168.1.5",
host_subnet="192.168.1.0/24",
state=None,
test_connection_success=True,
report_messages_since_connect: int | None = 5,
@@ -82,9 +90,13 @@ class _Env:
self.ports = ports or _port_probe()
# What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
self.ftps = ftps
self.in_docker = in_docker
# Container engine detect_container_runtime() reports, None for bare metal.
self.runtime = runtime
self.network_mode = network_mode
self.host_ip = host_ip
# The prefix the host's own interface carries. A string so a test can
# say /22 as easily as /24; None means no interface claims host_ip.
self.host_subnet = host_subnet
self.state = state
self.test_connection_success = test_connection_success
# ``None`` means get_client returns None (e.g. pre-add flow); an int
@@ -120,9 +132,15 @@ class _Env:
manager.get_client.return_value = client
self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
self._stack.enter_context(patch(f"{MOD}._check_ftps_tls", new_callable=AsyncMock, return_value=self.ftps))
self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
self._stack.enter_context(patch(f"{MOD}.detect_container_runtime", return_value=self.runtime))
self._stack.enter_context(patch(f"{MOD}._detect_container_network_mode", return_value=self.network_mode))
self._stack.enter_context(patch(f"{MOD}._host_source_ip", return_value=self.host_ip))
self._stack.enter_context(
patch(
f"{MOD}.find_local_ipv4_network",
return_value=ipaddress.ip_network(self.host_subnet) if self.host_subnet else None,
)
)
self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
self._stack.enter_context(patch(f"{MOD}.find_remote_file_async", new=self.find_remote_file))
return self
@@ -138,12 +156,41 @@ def _printer(ip="192.168.1.50", model=None, access_code="12345678"):
return types.SimpleNamespace(id=1, ip_address=ip, model=model, access_code=access_code)
def _with_host_network(subnet: str | None):
"""Patch the host's own interface prefix, the way the kernel reports it."""
return patch(
f"{MOD}.find_local_ipv4_network",
return_value=ipaddress.ip_network(subnet) if subnet else None,
)
class TestSameSubnet:
def test_same_24(self):
assert _same_subnet("192.168.1.10", "192.168.1.200") is True
with _with_host_network("192.168.1.0/24"):
assert _same_subnet("192.168.1.10", "192.168.1.200") is True
def test_different_24(self):
assert _same_subnet("192.168.1.10", "192.168.2.10") is False
with _with_host_network("192.168.2.0/24"):
assert _same_subnet("192.168.1.10", "192.168.2.10") is False
def test_a_22_reaches_across_the_third_octet(self):
"""#3092: 192.168.96.9/22 and 192.168.98.170 are one LAN.
The old code built both sides as /24 and told the reporter his printer
was on a different network, four hundred addresses inside his own.
"""
with _with_host_network("192.168.96.0/22"):
assert _same_subnet("192.168.98.170", "192.168.96.9") is True
def test_a_25_does_not_reach_the_whole_24(self):
"""The assumption cut both ways: a /25 is narrower than the guess."""
with _with_host_network("192.168.1.0/25"):
assert _same_subnet("192.168.1.200", "192.168.1.10") is False
def test_no_interface_claims_the_host_address(self):
"""Undeterminable stays undeterminable — it must not become a warning."""
with _with_host_network(None):
assert _same_subnet("192.168.1.10", "192.168.1.200") is None
def test_hostname_undeterminable(self):
assert _same_subnet("printer.local", "192.168.1.10") is None
@@ -152,6 +199,41 @@ class TestSameSubnet:
assert _same_subnet("fe80::1", "192.168.1.10") is None
class TestHostSourceIp:
"""#3092: which of Bambuddy's own addresses the comparison is made against."""
def test_the_route_is_probed_toward_the_printer(self):
"""Not toward a fixed far-away address.
On a multi-homed host the source for a route to the internet is a
different interface from the one the printer is on, and the old fixed
10.255.255.255 probe compared the printer against that one.
"""
sock = MagicMock()
sock.getsockname.return_value = ("192.168.96.9", 51234)
with patch(f"{MOD}.socket.socket", return_value=sock):
assert _host_source_ip("192.168.98.170") == "192.168.96.9"
sock.connect.assert_called_once_with(("192.168.98.170", 1))
sock.close.assert_called_once()
def test_a_hostname_is_never_resolved(self):
"""connect() on a name blocks the event loop; _same_subnet needs a literal anyway."""
with patch(f"{MOD}.socket.socket") as factory:
assert _host_source_ip("printer.local") is None
factory.assert_not_called()
def test_ipv6_destination_is_refused(self):
with patch(f"{MOD}.socket.socket") as factory:
assert _host_source_ip("fe80::1") is None
factory.assert_not_called()
def test_an_unreachable_route_is_not_an_error(self):
sock = MagicMock()
sock.connect.side_effect = OSError("Network is unreachable")
with patch(f"{MOD}.socket.socket", return_value=sock):
assert _host_source_ip("192.168.98.170") is None
class TestExistingPrinter:
async def test_all_healthy(self):
with _Env(
@@ -258,16 +340,58 @@ class TestExistingPrinter:
# Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
assert s["subnet"] == "skip"
async def test_network_mode_skipped_outside_docker(self):
with _Env(in_docker=False, state=_state()):
async def test_network_mode_skipped_outside_a_container(self):
with _Env(runtime=None, state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
assert _statuses(result)["network_mode"] == "skip"
async def test_podman_host_networking_passes(self):
"""#3092: the same two shapes as Docker, and they must read the same."""
with _Env(runtime="Podman", network_mode="host", state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
check = _check(result, "network_mode")
assert check.status == "pass"
assert check.params["runtime"] == "Podman"
async def test_podman_bridge_networking_warns(self):
with _Env(runtime="Podman", network_mode="bridge", state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
assert _statuses(result)["network_mode"] == "warn"
async def test_undetectable_mode_skips_rather_than_guessing(self):
"""A container we cannot read must not be told to recreate itself."""
with _Env(runtime="Podman", network_mode=None, state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
check = _check(result, "network_mode")
assert check.status == "skip"
assert check.params == {"reason": "unknown", "runtime": "Podman"}
async def test_system_container_has_no_network_mode_to_recommend(self):
"""LXC/LXD is bridged onto the LAN like a small VM — nothing to fix."""
with _Env(runtime="LXC", state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
check = _check(result, "network_mode")
assert check.status == "skip"
assert check.params == {"reason": "system_container", "runtime": "LXC"}
# And the subnet check still runs: an LXC container is on the LAN.
assert _statuses(result)["subnet"] == "pass"
async def test_different_subnet_warns(self):
with _Env(host_ip="10.0.0.5", state=_state()):
with _Env(host_ip="10.0.0.5", host_subnet="10.0.0.0/24", state=_state()):
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
assert _statuses(result)["subnet"] == "warn"
async def test_a_wider_lan_is_not_a_different_subnet(self):
"""#3092 end to end, with the reporter's own addresses."""
with _Env(host_ip="192.168.96.9", host_subnet="192.168.96.0/22", state=_state()):
result = await run_connection_diagnostic("192.168.98.170", printer=_printer(ip="192.168.98.170"))
assert _statuses(result)["subnet"] == "pass"
async def test_unknown_host_prefix_skips_rather_than_warning(self):
with _Env(host_ip="192.168.96.9", host_subnet=None, state=_state()):
result = await run_connection_diagnostic("192.168.98.170", printer=_printer(ip="192.168.98.170"))
assert _statuses(result)["subnet"] == "skip"
async def test_printer_publishing_passes_when_reports_seen(self):
# Counter > 0 means the printer is publishing on the report topic.
with _Env(state=_state(), report_messages_since_connect=1):