fix(install): sign the Python that macOS grants local network access to (issue #3114)

macOS attributes Local Network permission to a code signature and judges a
launchd-spawned process on its own, rather than letting it inherit the grant
of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
there is no identity for the grant to attach to: every connection to a LAN
address is dropped with no error the application can log and no permission
prompt. The printer reads as unreachable and nothing says why, and the entry
in Privacy & Security cannot be made to work because it refers to an identity
that no longer resolves.

install.sh signs during a macOS install; update_macos.sh re-checks on every
update, because `brew upgrade python` installs a fresh unsigned binary under
a new versioned path.

Both sign only what is currently unsigned. That gate is load-bearing: on
arm64 the linker ad-hoc signs every binary and the identity is a hash of the
file, so re-signing would rotate it and revoke a working grant on each update.
A python.org build carries a real Developer ID and must not be downgraded for
the same reason.

The interpreter and the framework's Python.app are both signed. The first is
what sys._base_executable resolves to and what the reporter's TCC log names;
the second is what his fix actually targeted. Which one macOS attributes
could not be established from either, and signing both costs nothing.

-----

fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)

The port checks reported all three ports unreachable while the subnet check
passed, and port_mqtt's fix text sent the reporter after firewalls and IP
addresses. On a macOS native install that pattern has a cause neither of
those covers: no Local Network grant, denied with no error and no prompt.

A new macos_local_network check, appended on macOS only so no permanently
dimmed row appears for anyone else. It passes when the control port answered,
which is proof the permission is in place and means the signature probe never
runs on a healthy diagnostic. Otherwise it probes the interpreter: an
unsigned one gets the repair that fixes it, a signed one gets System Settings
— the arm64 case, where the identity is a hash of the binary, so a Python
upgrade presents macOS with a new application and strands the old grant.

Always warn, never fail, and only once port_mqtt has already failed, so this
can never be why a green diagnostic turns red. A printer that is simply
switched off produces the same all-ports-dead pattern, which is why the
signature, not the pattern, is what earns the specific advice. An
undeterminable signature is reported as the generic case rather than as
unsigned: that advice rewrites a file in the user's Python installation and
must not be offered on a guess.
This commit is contained in:
maziggy
2026-09-19 16:03:52 +02:00
parent 70b42d1c8d
commit 1ccaf74dd5
23 changed files with 505 additions and 17 deletions
+6 -2
View File
@@ -446,10 +446,14 @@ class PrinterStatus(BaseModel):
class DiagnosticCheck(BaseModel):
"""One connection-diagnostic check result.
``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
subnet, mqtt_auth, developer_mode); the frontend renders the localized
``id`` is a stable key (port_mqtt, port_ftps, port_rtsps,
macos_local_network, network_mode, subnet, external_storage, mqtt_auth,
developer_mode, printer_publishing); the frontend renders the localized
title and fix text from id + status. ``params`` carries interpolation
values (e.g. network mode, IP addresses) for that text.
Not every check is emitted on every run: ``macos_local_network`` appears
only on macOS, where it is the only platform it can say anything about.
"""
id: str
+103
View File
@@ -12,8 +12,11 @@ user-side setup errors clustered on exactly these causes.
import asyncio
import ipaddress
import logging
import os
import socket
import ssl
import subprocess
import sys
from pathlib import Path
from backend.app.models.printer import Printer
@@ -336,6 +339,73 @@ def _same_subnet(printer_ip: str, host_ip: str) -> bool | None:
return printer_addr in network
# macOS attributes Local Network permission (TCC) to a process's code
# signature, and judges a launchd-spawned process on its own instead of
# letting it inherit the grant of the Terminal that started it. Homebrew's
# Python is unsigned on Intel, so there is no identity for a grant to attach
# to: every connection to a LAN address is dropped, with no error the
# application can log and no permission prompt. All three printer ports read
# as unreachable while the subnet check passes (#3114).
_CODESIGN = "/usr/bin/codesign"
# Reading a local file's signature takes milliseconds, so this is a guard
# rather than a budget -- and it is deliberately short. The support bundle
# gives each printer 15s total (_PER_DIAGNOSTIC_TIMEOUT_SECONDS) and drops
# the whole connection diagnostic on overrun, so a codesign that hangs (the
# stub that offers to install the command line tools is the plausible way)
# must not be able to cost the bundle the rest of its checks.
_CODESIGN_TIMEOUT = 2.0
def _base_interpreter_path() -> str:
"""The interpreter macOS judges, as both the probe and the message see it.
``sys._base_executable`` rather than ``sys.executable``: inside a venv the
latter is a symlink in the venv's own bin directory, and what macOS judges
is the real interpreter it resolves to. Resolved once, here, so the path
reported to the user is the same one whose signature was read.
"""
return os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
def _interpreter_is_signed() -> bool | None:
"""Does the interpreter Bambuddy runs under carry a code signature?
None when it cannot be told: no usable ``codesign`` because the Xcode
command line tools are absent, or the probe failed some other way. That
is deliberately not folded into False. The advice for "no identity" names
a repair that rewrites a file inside the user's Python installation, and
offering that on a guess is worse than giving the generic answer.
On an Apple Silicon Homebrew install the interpreter resolves to the
framework's ``bin/pythonX.Y`` (measured, inside and outside a venv alike)
-- not the ``Python.app`` stub, which is a separate binary in the same
framework. The reporter's TCC log names the same ``bin/pythonX.Y`` on
Intel.
"""
executable = _base_interpreter_path()
if not executable:
return None
try:
result = subprocess.run(
[_CODESIGN, "-d", executable],
capture_output=True,
text=True,
timeout=_CODESIGN_TIMEOUT,
)
except Exception:
# Fail soft, as everywhere else in this module: a diagnostic that
# raises is worse than one that declines to answer.
logger.debug("codesign probe failed", exc_info=True)
return None
if result.returncode == 0:
return True
# codesign writes this to stderr and exits non-zero. It is the one
# outcome that separates "no identity at all" from "the probe never ran".
if "not signed at all" in result.stderr:
return False
return None
async def run_connection_diagnostic(
ip_address: str,
*,
@@ -381,6 +451,39 @@ async def run_connection_diagnostic(
)
)
# --- macOS Local Network permission ---
# Appended on macOS only. Everywhere else there is nothing to say, and a
# permanently dimmed "skipped" row would be noise for the users who make
# up nearly all of them.
#
# Both outcomes are reported as warn rather than fail, and only when the
# control port is already unreachable -- so this can never be the check
# that turns an otherwise healthy result red. A printer that is simply
# switched off produces the same all-ports-dead pattern, which is why the
# signature probe, not the pattern, is what earns the specific advice.
if sys.platform == "darwin":
if mqtt_ok:
# The control port answered, so LAN access demonstrably works.
checks.append(DiagnosticCheck(id="macos_local_network", status="pass"))
else:
signed = await asyncio.to_thread(_interpreter_is_signed)
if signed is False:
checks.append(
DiagnosticCheck(
id="macos_local_network",
status="warn",
params={"reason": "unsigned", "executable": _base_interpreter_path()},
)
)
else:
# Signed, or undeterminable. An ad-hoc signature -- which is
# what every arm64 binary carries, because the linker adds one
# -- identifies itself by a hash of the binary, so a Python
# upgrade presents macOS with a new application and leaves the
# old grant behind. That is repairable in System Settings,
# unlike the unsigned case, so point there instead.
checks.append(DiagnosticCheck(id="macos_local_network", status="warn", params={"reason": "permission"}))
# --- 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