Browse Source

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.
maziggy 12 hours ago
parent
commit
3a5f802cdc

+ 2 - 0
CHANGELOG.md

@@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **The connection diagnostic told anyone whose LAN was not a /24 that their printer was on a different network (#3092, reported by @cwawak)** — the reporter's LAN is `192.168.96.0/22`, his Bambuddy sits at `192.168.96.9` and his printer at `192.168.98.170`, four hundred addresses inside the same subnet. The *Network subnet* check warned that the two were on different networks and told him to go configure routing between two halves of one LAN. An IPv4 address does not carry its prefix, and the check supplied `/24` for both sides — the most common LAN, and not the only one. It now reads the prefix off the interface that owns the address, so a `/22`, a `/16` and a `/25` are each read as written, and when no local interface claims the address the check is skipped rather than asserting a split it cannot see. The same check also picked which of Bambuddy's own addresses to compare by probing a route toward `10.255.255.255`, which on any host with more than one network card is not the card the printer is on; it now asks for the route toward the printer itself. On a two-NIC host that alone was enough to warn about a printer sitting on the second card's own subnet.
+- **Podman and LXC installs were told they were not running in a container at all (#3092, reported by @cwawak)** — the *Docker network mode* check said "Not running in Docker — not applicable" to a Bambuddy inside a Podman container, which reads as "you are on bare metal" and sends people looking for the problem somewhere else. Podman runs Bambuddy in exactly the same two shapes Docker does, and the shape is what breaks printer discovery and the Virtual Printer. The check is now **Container network mode**: it names the engine — Docker, Podman, Kubernetes, containerd — and reports host or bridge networking for each of them. Detection of the mode itself gained the general form of the signal it always used: a container that can see an interface created in its own network namespace, rather than only one end of a veth pair, is sharing the host's. A container whose mode genuinely cannot be read now says so and suggests host networking if discovery is failing, instead of guessing "bridge" and telling a perfectly healthy install to recreate itself. An LXC or LXD system container is named too, and told that the question does not apply to it — it sits on the LAN like a small virtual machine, so there is no network mode to recommend. The support bundle now carries the engine name beside the Docker flag for the same reason. Discovery's own Docker detection is deliberately untouched: it switches the Add Printer flow from SSDP to subnet scanning, and SSDP works for a host-networked Podman container.
 - **SpoolBuddy said "Unknown color" for spools Bambuddy names perfectly well (#3090, reported by @Sawtaytoes)** — the reporter scanned a Bambu Lab PLA Silk+ spool that his inventory card calls Candy Red and the kiosk showed the right red swatch above the words "Unknown color". The name was never in the spool record: Bambu's RFID tags often carry none, so Bambuddy has always resolved the swatch's own hex against the colour catalog instead — and the kiosk was printing the empty column. Every screen in SpoolBuddy that shows a colour now resolves it the same way the rest of Bambuddy does, which also stops the ones that had a name on file from showing Bambu's internal code ("A06-D0") in place of it. Searching the inventory by a colour you can read on screen finds it now, in the kiosk and in Bambuddy, instead of matching only what was stored. On Spoolman-backed inventory the catalog also wins over the spool's subtype, which Spoolman installs were being shown as a colour name because Spoolman has no field for one — so those spools said "Silk+" where they now say Candy Red. The label the kiosk falls back to when nothing can name a colour is translated in all 14 languages rather than being English for everyone.
 - **An external RTSP camera could pass the connection test and still show a black live view (#3082, reported by @M1XZG)** — the reporter's Wyze cam, republished through go2rtc, captured a frame for the test button and played in VLC, but the live view produced no image and ended after a few seconds. The two RTSP paths were not asking ffmpeg for the same thing: the one-shot capture used ffmpeg's own probe defaults, while the live stream hard-coded a 32-byte probe with no analysis. That is enough for a camera that describes itself in its SDP, and not enough for one that sends its H.264 parameters in-band a moment later — ffmpeg then never starts a decoder and yields nothing at all. The live stream now probes on the defaults like the capture beside it, so the test button means what it appears to mean. The low-latency settings are unchanged, and so is the camera handling for Bambu's own printers, which is tuned per model against a camera we know.
 - **The jog API pushed an A1's nozzle at the plate when asked for clearance (#1334, reported by @AQU4R1U5)** — `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap: positive asks for more room between the nozzle and the plate. The reporter sent `distance=5` to his A1 and the toolhead came down instead. The endpoint had been flipping the sign on A1 models since the original report on this issue, where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched the nozzle dive — but that flip was solving a labelling problem in the transport layer, and it turned a parameter documented as model-independent into one that meant the opposite thing on a quarter of the fleet. `Z` is the nozzle-to-bed distance on every Bambu model, whether the bed drops away from a fixed nozzle or the nozzle rises off a fixed bed, so the endpoint now sends `distance` through unchanged and one call means one physical outcome everywhere: positive is the safe direction on every printer. The arrows on the printer card are unchanged and still show you what you would expect to move — the card works out which gap change its own arrows stand for, which is where a question about the machine in front of you belongs. On an A1, A1 Mini or A2L those buttons now say *toolhead* rather than *plate*, since a bed-slinger's plate does not move in Z at all. **A2L owners had none of this**: the original fix listed the A1 models by name and the A2L, which slings its bed the same way, was never on the list — its up arrow has been sending the toolhead down since the machine was supported.

+ 6 - 1
backend/app/api/routes/support.py

@@ -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
backend/app/services/diagnostic_snapshot.py

@@ -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
backend/app/services/discovery.py

@@ -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
backend/app/services/network_utils.py

@@ -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
backend/app/services/printer_diagnostic.py

@@ -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 _host_source_ip(destination_ip: str) -> str | None:
+    """The local IPv4 address Bambuddy would send from toward ``destination_ip``.
 
-def _get_host_ip() -> str | None:
-    """Best-effort IPv4 address the Bambuddy host routes from."""
+    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
+
+    network = find_local_ipv4_network(str(host_addr))
+    if network is None:
         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
+    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:

+ 259 - 0
backend/tests/unit/services/test_container_runtime_3092.py

@@ -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

+ 47 - 0
backend/tests/unit/services/test_network_utils.py

@@ -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

+ 134 - 10
backend/tests/unit/services/test_printer_diagnostic.py

@@ -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):

+ 70 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -175,6 +175,76 @@ describe('ConnectionDiagnosticModal', () => {
     spy.mockRestore();
   });
 
+  it('names the container engine in the network-mode check (#3092)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'ok',
+      checks: [{ id: 'network_mode', status: 'pass', params: { mode: 'host', runtime: 'Podman' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    // The title is no longer Docker-specific, and the engine the user
+    // actually runs is named back to them.
+    expect(await screen.findByText(/Container network mode/i)).toBeInTheDocument();
+    expect(screen.getByText(/Running in Podman with host networking/i)).toBeInTheDocument();
+    expect(screen.queryByText(/Docker network mode/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('localizes an unnamed container engine instead of interpolating a raw word (#3092)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'ok',
+      checks: [{ id: 'network_mode', status: 'pass', params: { mode: 'host', runtime: 'container' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    // "a container", not the bare backend sentinel — the same slot carries a
+    // localized noun phrase in every other locale.
+    expect(await screen.findByText(/Running in a container with host networking/i)).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('says so when a container network mode cannot be read, instead of guessing (#3092)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'warnings',
+      checks: [{ id: 'network_mode', status: 'skip', params: { reason: 'unknown', runtime: 'Podman' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/network mode could not be determined/i)).toBeInTheDocument();
+    // Must not claim bare metal, which is what sent the reporter looking
+    // for the problem somewhere else.
+    expect(screen.queryByText(/not running in a container/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('does not offer host networking to a system container (#3092)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'ok',
+      checks: [{ id: 'network_mode', status: 'skip', params: { reason: 'system_container', runtime: 'LXC' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/LXC system container/i)).toBeInTheDocument();
+    expect(screen.queryByText(/recreate the container/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
   it('falls back to the generic skip text when no reason is present', async () => {
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
       ...PROBLEM_RESULT,

+ 9 - 1
frontend/src/components/ConnectionDiagnostic.tsx

@@ -40,10 +40,18 @@ export function DiagnosticChecklist({ result }: { result: PrinterDiagnosticResul
         : 'bg-red-50 dark:bg-red-500/10 border-red-300 dark:border-red-500/30 text-red-700 dark:text-red-300';
 
   const renderCheck = (check: DiagnosticCheck) => {
+    // The network_mode check names the container engine it found. Docker,
+    // Podman and the rest are proper nouns and read correctly in every
+    // language; a container it could not name arrives as this sentinel and
+    // is localized here, rather than dropping an English word into the other
+    // thirteen locales (#3092). Keep in step with RUNTIME_OTHER in
+    // backend/app/services/discovery.py.
     const params =
       check.id === 'port_rtsps'
         ? { protocol: 'RTSPS', port: 322, ...check.params }
-        : check.params;
+        : check.id === 'network_mode' && check.params?.runtime === 'container'
+          ? { ...check.params, runtime: t('diagnostic.check.network_mode.genericRuntime') }
+          : check.params;
     // A check may carry a `reason` to select a more specific message variant
     // (e.g. external_storage skip on P1-series → skip_unsupported_model #2524);
     // fall back to the plain per-status text when no variant key exists.

+ 7 - 4
frontend/src/i18n/locales/de.ts

@@ -6989,10 +6989,13 @@ export default {
         warn: 'Port {{port}} ist nicht erreichbar. Die Live-Kameraansicht funktioniert nicht. Dies betrifft das Drucken nicht.',
       },
       network_mode: {
-        title: 'Docker-Netzwerkmodus',
-        pass: 'Läuft im Host-Netzwerkmodus.',
-        warn: 'Bambuddy läuft im Docker-Bridge-Netzwerkmodus. Die Druckererkennung und der virtuelle Drucker benötigen den Host-Netzwerkmodus — erstellen Sie den Container mit "network_mode: host" neu.',
-        skip: 'Läuft nicht in Docker — nicht zutreffend.',
+        title: 'Container-Netzwerkmodus',
+        genericRuntime: 'einem Container',
+        pass: 'Läuft in {{runtime}} mit Host-Netzwerk.',
+        warn: 'Bambuddy läuft in {{runtime}} mit Bridge-Netzwerk. Die Druckererkennung und der virtuelle Drucker benötigen den Host-Netzwerkmodus — erstellen Sie den Container mit Host-Netzwerk neu ("network_mode: host" in docker-compose, "--network=host" bei Podman).',
+        skip: 'Läuft nicht in einem Container — nicht zutreffend.',
+        skip_unknown: 'Bambuddy läuft in {{runtime}}, der Netzwerkmodus konnte jedoch nicht ermittelt werden. Falls die Druckererkennung oder der virtuelle Drucker nicht funktionieren, erstellen Sie den Container mit Host-Netzwerk neu.',
+        skip_system_container: 'Bambuddy läuft in einem {{runtime}}-Systemcontainer, der wie eine virtuelle Maschine direkt im LAN liegt — nicht zutreffend.',
       },
       subnet: {
         title: 'Netzwerk-Subnetz',

+ 7 - 4
frontend/src/i18n/locales/en.ts

@@ -7040,10 +7040,13 @@ export default {
         warn: 'Port {{port}} is unreachable. The live camera view will not work. This does not affect printing.',
       },
       network_mode: {
-        title: 'Docker network mode',
-        pass: 'Running in host network mode.',
-        warn: 'Bambuddy is running in Docker bridge networking. Printer discovery and the Virtual Printer need host network mode — recreate the container with "network_mode: host".',
-        skip: 'Not running in Docker — not applicable.',
+        title: 'Container network mode',
+        genericRuntime: 'a container',
+        pass: 'Running in {{runtime}} with host networking.',
+        warn: 'Bambuddy is running in {{runtime}} with bridge networking. Printer discovery and the Virtual Printer need host networking — recreate the container with host network mode ("network_mode: host" in docker-compose, "--network=host" for Podman).',
+        skip: 'Not running in a container — not applicable.',
+        skip_unknown: 'Bambuddy is running in {{runtime}}, but its network mode could not be determined. If printer discovery or the Virtual Printer do not work, recreate the container with host networking.',
+        skip_system_container: 'Bambuddy is running in a {{runtime}} system container, which sits on the LAN like a virtual machine — not applicable.',
       },
       subnet: {
         title: 'Network subnet',

+ 7 - 4
frontend/src/i18n/locales/es.ts

@@ -6997,10 +6997,13 @@ export default {
         warn: 'El puerto {{port}} no es accesible. La vista de la cámara en directo no funcionará. Esto no afecta a la impresión.',
       },
       network_mode: {
-        title: 'Modo de red de Docker',
-        pass: 'Ejecutándose en modo de red de host.',
-        warn: 'Bambuddy se está ejecutando en red de tipo bridge de Docker. La detección de impresoras y la impresora virtual necesitan el modo de red de host — vuelva a crear el contenedor con "network_mode: host".',
-        skip: 'No se está ejecutando en Docker — no aplicable.',
+        title: 'Modo de red del contenedor',
+        genericRuntime: 'un contenedor',
+        pass: 'Ejecutándose en {{runtime}} con red de host.',
+        warn: 'Bambuddy se está ejecutando en {{runtime}} con red de tipo bridge. La detección de impresoras y la impresora virtual necesitan el modo de red de host — vuelva a crear el contenedor con red de host ("network_mode: host" en docker-compose, "--network=host" en Podman).',
+        skip: 'No se está ejecutando en un contenedor — no aplicable.',
+        skip_unknown: 'Bambuddy se está ejecutando en {{runtime}}, pero no se pudo determinar su modo de red. Si la detección de impresoras o la impresora virtual no funcionan, vuelva a crear el contenedor con red de host.',
+        skip_system_container: 'Bambuddy se está ejecutando en un contenedor de sistema {{runtime}}, que está en la LAN como una máquina virtual — no aplicable.',
       },
       subnet: {
         title: 'Subred de la red',

+ 7 - 4
frontend/src/i18n/locales/fr.ts

@@ -6979,10 +6979,13 @@ export default {
         warn: 'Le port {{port}} est inaccessible. La vue caméra en direct ne fonctionnera pas. Cela n\'affecte pas l\'impression.',
       },
       network_mode: {
-        title: 'Mode réseau Docker',
-        pass: 'Fonctionne en mode réseau host.',
-        warn: 'Bambuddy fonctionne en réseau Docker bridge. La découverte d\'imprimantes et l\'imprimante virtuelle nécessitent le mode réseau host — recréez le conteneur avec "network_mode: host".',
-        skip: 'Ne fonctionne pas dans Docker — non applicable.',
+        title: 'Mode réseau du conteneur',
+        genericRuntime: 'un conteneur',
+        pass: 'Fonctionne dans {{runtime}} en réseau host.',
+        warn: 'Bambuddy fonctionne dans {{runtime}} en réseau bridge. La découverte d\'imprimantes et l\'imprimante virtuelle nécessitent le mode réseau host — recréez le conteneur en réseau host ("network_mode: host" dans docker-compose, "--network=host" pour Podman).',
+        skip: 'Ne fonctionne pas dans un conteneur — non applicable.',
+        skip_unknown: 'Bambuddy fonctionne dans {{runtime}}, mais son mode réseau n\'a pas pu être déterminé. Si la découverte d\'imprimantes ou l\'imprimante virtuelle ne fonctionnent pas, recréez le conteneur en réseau host.',
+        skip_system_container: 'Bambuddy fonctionne dans un conteneur système {{runtime}}, présent sur le LAN comme une machine virtuelle — non applicable.',
       },
       subnet: {
         title: 'Sous-réseau',

+ 7 - 4
frontend/src/i18n/locales/it.ts

@@ -6978,10 +6978,13 @@ export default {
         warn: 'La porta {{port}} non è raggiungibile. La visualizzazione live della fotocamera non funzionerà. Questo non influisce sulla stampa.',
       },
       network_mode: {
-        title: 'Modalità di rete Docker',
-        pass: 'In esecuzione in modalità di rete host.',
-        warn: 'Bambuddy è in esecuzione con la rete Docker bridge. Il rilevamento delle stampanti e la stampante virtuale richiedono la modalità di rete host — ricrea il container con "network_mode: host".',
-        skip: 'Non in esecuzione in Docker — non applicabile.',
+        title: 'Modalità di rete del container',
+        genericRuntime: 'un container',
+        pass: 'In esecuzione in {{runtime}} con rete host.',
+        warn: 'Bambuddy è in esecuzione in {{runtime}} con rete bridge. Il rilevamento delle stampanti e la stampante virtuale richiedono la modalità di rete host — ricrea il container con rete host ("network_mode: host" in docker-compose, "--network=host" per Podman).',
+        skip: 'Non in esecuzione in un container — non applicabile.',
+        skip_unknown: 'Bambuddy è in esecuzione in {{runtime}}, ma non è stato possibile determinarne la modalità di rete. Se il rilevamento delle stampanti o la stampante virtuale non funzionano, ricrea il container con rete host.',
+        skip_system_container: 'Bambuddy è in esecuzione in un container di sistema {{runtime}}, presente sulla LAN come una macchina virtuale — non applicabile.',
       },
       subnet: {
         title: 'Sottorete',

+ 7 - 4
frontend/src/i18n/locales/ja.ts

@@ -6990,10 +6990,13 @@ export default {
         warn: 'ポート{{port}}に到達できません。ライブカメラ表示は機能しません。これは印刷には影響しません。',
       },
       network_mode: {
-        title: 'Dockerネットワークモード',
-        pass: 'ホストネットワークモードで実行中です。',
-        warn: 'BambuddyはDockerブリッジネットワークで実行されています。プリンター検出と仮想プリンターにはホストネットワークモードが必要です — "network_mode: host" でコンテナを再作成してください。',
-        skip: 'Dockerで実行されていません — 該当しません。',
+        title: 'コンテナのネットワークモード',
+        genericRuntime: 'コンテナ',
+        pass: '{{runtime}} でホストネットワークを使用して実行中です。',
+        warn: 'Bambuddy は {{runtime}} でブリッジネットワークを使用して実行されています。プリンター検出と仮想プリンターにはホストネットワークが必要です — ホストネットワークでコンテナを再作成してください(docker-compose では "network_mode: host"、Podman では "--network=host")。',
+        skip: 'コンテナで実行されていません — 該当しません。',
+        skip_unknown: 'Bambuddy は {{runtime}} で実行されていますが、ネットワークモードを判別できませんでした。プリンター検出や仮想プリンターが動作しない場合は、ホストネットワークでコンテナを再作成してください。',
+        skip_system_container: 'Bambuddy は {{runtime}} のシステムコンテナで実行されており、仮想マシンと同様に LAN 上にあります — 該当しません。',
       },
       subnet: {
         title: 'ネットワークサブネット',

+ 7 - 4
frontend/src/i18n/locales/ko.ts

@@ -7090,10 +7090,13 @@ export default {
         warn: '포트 {{port}}에 연결할 수 없습니다. 라이브 카메라 보기가 작동하지 않습니다. 인쇄에는 영향을 주지 않습니다.'
       },
       network_mode: {
-        title: 'Docker 네트워크 모드',
-        pass: '호스트 네트워크 모드로 실행 중입니다.',
-        warn: 'Bambuddy가 Docker 브리지 네트워킹으로 실행 중입니다. 프린터 검색과 가상 프린터에는 호스트 네트워크 모드가 필요합니다 — "network_mode: host"로 컨테이너를 재생성하세요.',
-        skip: 'Docker에서 실행 중이 아닙니다 — 해당 없음.'
+        title: '컨테이너 네트워크 모드',
+        genericRuntime: '컨테이너',
+        pass: '{{runtime}}에서 호스트 네트워크로 실행 중입니다.',
+        warn: 'Bambuddy가 {{runtime}}에서 브리지 네트워크로 실행 중입니다. 프린터 검색과 가상 프린터에는 호스트 네트워크가 필요합니다 — 호스트 네트워크로 컨테이너를 재생성하세요(docker-compose에서는 "network_mode: host", Podman에서는 "--network=host").',
+        skip: '컨테이너에서 실행 중이 아닙니다 — 해당 없음.',
+        skip_unknown: 'Bambuddy가 {{runtime}}에서 실행 중이지만 네트워크 모드를 확인할 수 없습니다. 프린터 검색이나 가상 프린터가 작동하지 않으면 호스트 네트워크로 컨테이너를 재생성하세요.',
+        skip_system_container: 'Bambuddy가 {{runtime}} 시스템 컨테이너에서 실행 중이며, 가상 머신처럼 LAN에 연결되어 있습니다 — 해당 없음.'
       },
       subnet: {
         title: '네트워크 서브넷',

+ 7 - 4
frontend/src/i18n/locales/nl.ts

@@ -7040,10 +7040,13 @@ export default {
         warn: 'Poort {{port}} is niet bereikbaar. De livecamera werkt niet. Dit heeft geen invloed op afdrukken.',
       },
       network_mode: {
-        title: 'Docker-netwerkmodus',
-        pass: 'Draait in host-netwerkmodus.',
-        warn: 'Bambuddy draait met Docker bridge-netwerken. Printerdetectie en de Virtuele printer vereisen host-netwerkmodus — maak de container opnieuw aan met "network_mode: host".',
-        skip: 'Niet actief in Docker — niet van toepassing.',
+        title: 'Netwerkmodus van de container',
+        genericRuntime: 'een container',
+        pass: 'Draait in {{runtime}} met host-netwerk.',
+        warn: 'Bambuddy draait in {{runtime}} met bridge-netwerken. Printerdetectie en de Virtuele printer vereisen host-netwerk — maak de container opnieuw aan met host-netwerk ("network_mode: host" in docker-compose, "--network=host" bij Podman).',
+        skip: 'Draait niet in een container — niet van toepassing.',
+        skip_unknown: 'Bambuddy draait in {{runtime}}, maar de netwerkmodus kon niet worden bepaald. Als printerdetectie of de Virtuele printer niet werken, maak de container dan opnieuw aan met host-netwerk.',
+        skip_system_container: 'Bambuddy draait in een {{runtime}}-systeemcontainer, die net als een virtuele machine op het LAN zit — niet van toepassing.',
       },
       subnet: {
         title: 'Netwerksubnet',

+ 7 - 4
frontend/src/i18n/locales/pt-BR.ts

@@ -6978,10 +6978,13 @@ export default {
         warn: 'A porta {{port}} está inacessível. A visualização ao vivo da câmera não funcionará. Isso não afeta a impressão.',
       },
       network_mode: {
-        title: 'Modo de rede Docker',
-        pass: 'Executando no modo de rede host.',
-        warn: 'O Bambuddy está sendo executado em rede Docker bridge. A descoberta de impressoras e a impressora virtual precisam do modo de rede host — recrie o contêiner com "network_mode: host".',
-        skip: 'Não está sendo executado no Docker — não aplicável.',
+        title: 'Modo de rede do contêiner',
+        genericRuntime: 'um contêiner',
+        pass: 'Executando em {{runtime}} com rede host.',
+        warn: 'O Bambuddy está sendo executado em {{runtime}} com rede bridge. A descoberta de impressoras e a impressora virtual precisam da rede host — recrie o contêiner com rede host ("network_mode: host" no docker-compose, "--network=host" no Podman).',
+        skip: 'Não está sendo executado em um contêiner — não aplicável.',
+        skip_unknown: 'O Bambuddy está sendo executado em {{runtime}}, mas não foi possível determinar o modo de rede. Se a descoberta de impressoras ou a impressora virtual não funcionarem, recrie o contêiner com rede host.',
+        skip_system_container: 'O Bambuddy está sendo executado em um contêiner de sistema {{runtime}}, que fica na LAN como uma máquina virtual — não aplicável.',
       },
       subnet: {
         title: 'Sub-rede',

+ 7 - 4
frontend/src/i18n/locales/ru.ts

@@ -6618,10 +6618,13 @@ export default {
         warn: "Порт {{port}} недоступен. Просмотр камеры в реальном времени работать не будет. На печать это не влияет.",
       },
       network_mode: {
-        title: "Сетевой режим Docker",
-        pass: "Используется сетевой режим host.",
-        warn: "Bambuddy запущен в Docker с сетью bridge. Для обнаружения принтеров и виртуального принтера требуется режим host — пересоздайте контейнер с параметром «network_mode: host».",
-        skip: "Bambuddy запущен не в Docker — проверка неприменима.",
+        title: "Сетевой режим контейнера",
+        genericRuntime: "контейнере",
+        pass: "Работает в {{runtime}} с сетью host.",
+        warn: "Bambuddy работает в {{runtime}} с сетью bridge. Для обнаружения принтеров и виртуального принтера требуется сеть host — пересоздайте контейнер с сетью host («network_mode: host» в docker-compose, «--network=host» для Podman).",
+        skip: "Bambuddy запущен не в контейнере — проверка неприменима.",
+        skip_unknown: "Bambuddy работает в {{runtime}}, но определить сетевой режим не удалось. Если обнаружение принтеров или виртуальный принтер не работают, пересоздайте контейнер с сетью host.",
+        skip_system_container: "Bambuddy работает в системном контейнере {{runtime}}, который находится в локальной сети как виртуальная машина — проверка неприменима.",
       },
       subnet: {
         title: "Подсеть",

+ 7 - 4
frontend/src/i18n/locales/tr.ts

@@ -6928,10 +6928,13 @@ export default {
         warn: 'Port {{port}} erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez.',
       },
       network_mode: {
-        title: 'Docker ağ modu',
-        pass: 'Ana bilgisayar ağ modunda çalışıyor.',
-        warn: 'Bambuddy, Docker köprü ağı kullanılarak çalışıyor. Yazıcı keşfi ve Sanal Yazıcı, ana bilgisayar ağ modu gerektirir — konteyneri "network_mode: host" ile yeniden oluşturun.',
-        skip: 'Docker\'da çalışmıyor — uygulanamaz.',
+        title: 'Konteyner ağ modu',
+        genericRuntime: 'bir konteyner',
+        pass: '{{runtime}} içinde ana bilgisayar ağıyla çalışıyor.',
+        warn: 'Bambuddy, {{runtime}} içinde köprü ağıyla çalışıyor. Yazıcı keşfi ve Sanal Yazıcı ana bilgisayar ağı gerektirir — konteyneri ana bilgisayar ağıyla yeniden oluşturun (docker-compose için "network_mode: host", Podman için "--network=host").',
+        skip: 'Bir konteyner içinde çalışmıyor — uygulanamaz.',
+        skip_unknown: 'Bambuddy {{runtime}} içinde çalışıyor ancak ağ modu belirlenemedi. Yazıcı keşfi veya Sanal Yazıcı çalışmıyorsa konteyneri ana bilgisayar ağıyla yeniden oluşturun.',
+        skip_system_container: 'Bambuddy, sanal makine gibi doğrudan yerel ağda bulunan bir {{runtime}} sistem konteyneri içinde çalışıyor — uygulanamaz.',
       },
       subnet: {
         title: 'Ağ alt ağı',

+ 7 - 4
frontend/src/i18n/locales/uk.ts

@@ -7032,10 +7032,13 @@ export default {
         warn: "Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.",
       },
       network_mode: {
-        title: "Мережевий режим Docker",
-        pass: "Працює в режимі хост-мережі.",
-        warn: "Bambuddy працює в мостовому мережевому режимі Docker. Для виявлення принтерів і роботи віртуального принтера потрібен режим мережі хоста — повторно створіть контейнер із параметром \"network_mode: host\".",
-        skip: "Не працює в Docker — не застосовується.",
+        title: "Мережевий режим контейнера",
+        genericRuntime: "контейнері",
+        pass: "Працює в {{runtime}} із мережею хоста.",
+        warn: "Bambuddy працює в {{runtime}} із мостовою мережею. Для виявлення принтерів і роботи віртуального принтера потрібна мережа хоста — повторно створіть контейнер із мережею хоста (\"network_mode: host\" у docker-compose, \"--network=host\" для Podman).",
+        skip: "Не працює в контейнері — не застосовується.",
+        skip_unknown: "Bambuddy працює в {{runtime}}, але визначити мережевий режим не вдалося. Якщо виявлення принтерів або віртуальний принтер не працюють, повторно створіть контейнер із мережею хоста.",
+        skip_system_container: "Bambuddy працює в системному контейнері {{runtime}}, який перебуває в локальній мережі як віртуальна машина — не застосовується.",
       },
       subnet: {
         title: "Мережева підмережа",

+ 7 - 4
frontend/src/i18n/locales/zh-CN.ts

@@ -6976,10 +6976,13 @@ export default {
         warn: '端口 {{port}} 不可达。实时摄像头视图将无法工作。这不影响打印。',
       },
       network_mode: {
-        title: 'Docker 网络模式',
-        pass: '正在以 host 网络模式运行。',
-        warn: 'Bambuddy 正在以 Docker bridge 网络运行。打印机发现和虚拟打印机需要 host 网络模式 — 请使用 "network_mode: host" 重新创建容器。',
-        skip: '未在 Docker 中运行 — 不适用。',
+        title: '容器网络模式',
+        genericRuntime: '容器',
+        pass: '正在 {{runtime}} 中以 host 网络运行。',
+        warn: 'Bambuddy 正在 {{runtime}} 中以 bridge 网络运行。打印机发现和虚拟打印机需要 host 网络 — 请使用 host 网络重新创建容器(docker-compose 中为 "network_mode: host",Podman 中为 "--network=host")。',
+        skip: '未在容器中运行 — 不适用。',
+        skip_unknown: 'Bambuddy 正在 {{runtime}} 中运行,但无法确定其网络模式。如果打印机发现或虚拟打印机无法使用,请使用 host 网络重新创建容器。',
+        skip_system_container: 'Bambuddy 正在 {{runtime}} 系统容器中运行,它像虚拟机一样直接位于局域网中 — 不适用。',
       },
       subnet: {
         title: '网络子网',

+ 7 - 4
frontend/src/i18n/locales/zh-TW.ts

@@ -6976,10 +6976,13 @@ export default {
         warn: '連接埠 {{port}} 無法連線。即時攝影機檢視將無法運作。這不影響列印。',
       },
       network_mode: {
-        title: 'Docker 網路模式',
-        pass: '正在以 host 網路模式執行。',
-        warn: 'Bambuddy 正在以 Docker bridge 網路執行。印表機探索與虛擬印表機需要 host 網路模式 — 請使用 "network_mode: host" 重新建立容器。',
-        skip: '未在 Docker 中執行 — 不適用。',
+        title: '容器網路模式',
+        genericRuntime: '容器',
+        pass: '正在 {{runtime}} 中以 host 網路執行。',
+        warn: 'Bambuddy 正在 {{runtime}} 中以 bridge 網路執行。印表機探索與虛擬印表機需要 host 網路 — 請使用 host 網路重新建立容器(docker-compose 中為 "network_mode: host",Podman 中為 "--network=host")。',
+        skip: '未在容器中執行 — 不適用。',
+        skip_unknown: 'Bambuddy 正在 {{runtime}} 中執行,但無法判斷其網路模式。如果印表機探索或虛擬印表機無法使用,請使用 host 網路重新建立容器。',
+        skip_system_container: 'Bambuddy 正在 {{runtime}} 系統容器中執行,它像虛擬機器一樣直接位於區域網路中 — 不適用。',
       },
       subnet: {
         title: '網路子網路',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DIyt0owc.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cxmp8Wcz.js"></script>
+    <script type="module" crossorigin src="/assets/index-DIyt0owc.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff