mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ so a status flip is a user-facing regression — each one is asserted here.
|
||||
|
||||
import ipaddress
|
||||
import ssl
|
||||
import subprocess
|
||||
import types
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -14,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from backend.app.services.printer_diagnostic import (
|
||||
_check_ftps_tls,
|
||||
_host_source_ip,
|
||||
_interpreter_is_signed,
|
||||
_same_subnet,
|
||||
run_connection_diagnostic,
|
||||
)
|
||||
@@ -77,6 +79,7 @@ class _Env:
|
||||
*,
|
||||
ports=None,
|
||||
ftps="ok",
|
||||
platform="linux",
|
||||
runtime="Docker",
|
||||
network_mode="host",
|
||||
host_ip="192.168.1.5",
|
||||
@@ -90,6 +93,11 @@ class _Env:
|
||||
self.ports = ports or _port_probe()
|
||||
# What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
|
||||
self.ftps = ftps
|
||||
# Pinned so the check list does not depend on the OS the suite runs
|
||||
# on: the macos_local_network check is emitted on darwin only (#3114),
|
||||
# and a test asserting the full set would otherwise pass on Linux CI
|
||||
# and fail on a maintainer's Mac.
|
||||
self.platform = platform
|
||||
# Container engine detect_container_runtime() reports, None for bare metal.
|
||||
self.runtime = runtime
|
||||
self.network_mode = network_mode
|
||||
@@ -130,6 +138,7 @@ class _Env:
|
||||
client.report_messages_since_connect = self.report_messages_since_connect
|
||||
client.last_connect_error = self.connect_error
|
||||
manager.get_client.return_value = client
|
||||
self._stack.enter_context(patch(f"{MOD}.sys.platform", self.platform))
|
||||
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}.detect_container_runtime", return_value=self.runtime))
|
||||
@@ -850,3 +859,111 @@ class TestFtpsTlsProbe:
|
||||
assert capped.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||
assert uncapped.maximum_version != ssl.TLSVersion.TLSv1_2
|
||||
assert uncapped.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||
|
||||
|
||||
def _signature_probe(signed):
|
||||
"""Patch ``_interpreter_is_signed`` to answer ``signed``.
|
||||
|
||||
The platform itself is pinned by ``_Env(platform="darwin")``, so that one
|
||||
patch cannot be undone by the environment entered after it.
|
||||
"""
|
||||
probe = MagicMock(return_value=signed)
|
||||
return patch(f"{MOD}._interpreter_is_signed", probe), probe
|
||||
|
||||
|
||||
class TestMacosLocalNetworkCheck:
|
||||
"""The macOS Local Network (TCC) check (#3114).
|
||||
|
||||
macOS attributes the permission to a code signature. An unsigned
|
||||
interpreter has no identity to anchor a grant to, so every connection to
|
||||
the printer is dropped with no error and no prompt — the ports read as
|
||||
unreachable and nothing says why.
|
||||
"""
|
||||
|
||||
async def test_absent_on_other_platforms(self):
|
||||
"""No dimmed "skipped" row for the users who are not on a Mac."""
|
||||
with _Env(platform="linux", state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert "macos_local_network" not in _statuses(result)
|
||||
|
||||
async def test_passes_when_the_control_port_answers(self):
|
||||
"""A reachable printer is proof the permission is in place."""
|
||||
patcher, probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert _statuses(result)["macos_local_network"] == "pass"
|
||||
# And the subprocess never runs on a healthy diagnostic.
|
||||
probe.assert_not_called()
|
||||
|
||||
async def test_unsigned_interpreter_names_the_repair(self):
|
||||
patcher, _probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
check = _check(result, "macos_local_network")
|
||||
assert check.status == "warn"
|
||||
assert check.params["reason"] == "unsigned"
|
||||
# The path is carried so the user can see which interpreter is meant.
|
||||
assert check.params["executable"]
|
||||
|
||||
async def test_signed_interpreter_points_at_system_settings(self):
|
||||
"""arm64 always has an ad-hoc signature, so this is the common case.
|
||||
|
||||
Its identity is a hash of the binary, so a Python upgrade presents
|
||||
macOS with a new application and strands the old grant. That is
|
||||
repairable in System Settings, unlike an unsigned interpreter.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(True)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
check = _check(result, "macos_local_network")
|
||||
assert check.status == "warn"
|
||||
assert check.params["reason"] == "permission"
|
||||
|
||||
async def test_undeterminable_signature_is_not_reported_as_unsigned(self):
|
||||
"""No codesign, no answer — and the specific advice is withheld.
|
||||
|
||||
It names a repair that rewrites a file in the user's Python install,
|
||||
which must not be offered on a guess.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(None)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert _check(result, "macos_local_network").params["reason"] == "permission"
|
||||
|
||||
async def test_never_turns_a_healthy_result_red(self):
|
||||
"""Only ever warn, and only when the port check already failed.
|
||||
|
||||
So this check cannot be the reason a diagnostic stops being green.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", state=_state(), report_messages_since_connect=42):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert result.overall == "ok"
|
||||
|
||||
|
||||
class TestInterpreterSignatureProbe:
|
||||
"""``codesign`` has three outcomes and they must stay distinguishable."""
|
||||
|
||||
def _run(self, **kwargs):
|
||||
return patch(f"{MOD}.subprocess.run", **kwargs)
|
||||
|
||||
def test_zero_exit_means_signed(self):
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=0, stderr="")):
|
||||
assert _interpreter_is_signed() is True
|
||||
|
||||
def test_not_signed_at_all_means_unsigned(self):
|
||||
stderr = "/usr/local/.../python3.14: code object is not signed at all"
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=1, stderr=stderr)):
|
||||
assert _interpreter_is_signed() is False
|
||||
|
||||
def test_other_failure_is_undeterminable(self):
|
||||
"""A bad path or a codesign that would not run is not evidence."""
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=1, stderr="No such file or directory")):
|
||||
assert _interpreter_is_signed() is None
|
||||
|
||||
def test_probe_failure_never_raises(self):
|
||||
"""A diagnostic that 500s the page is worse than one that says nothing."""
|
||||
with self._run(side_effect=OSError("boom")):
|
||||
assert _interpreter_is_signed() is None
|
||||
with self._run(side_effect=subprocess.TimeoutExpired(cmd="codesign", timeout=5.0)):
|
||||
assert _interpreter_is_signed() is None
|
||||
|
||||
Reference in New Issue
Block a user