mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +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
|
||||
|
||||
Reference in New Issue
Block a user