Ship signed universal macOS probo-agent pkg

Publish a notarized arm64+x86_64 .pkg from CI with the CGO tray
binary, Probo Agent.app, and global LaunchAgent. Keep the
LaunchDaemon enrollment-gated, align its plist path with the
launchd label, and document the Apple signing secrets.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-17 13:59:16 +02:00
parent 4a56be2e3e
commit afe0c84881
8 changed files with 528 additions and 78 deletions

View File

@@ -6,8 +6,9 @@
Placeholders are substituted by build.sh:
@@VERSION@@ agent version, e.g. 0.1.0
@@PKG_ARCH@@ payload pkgbuild architecture (x86_64 or arm64)
@@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@)
@@HOST_ARCHS@@ host arch filter used by Installer.app
(e.g. arm64 or arm64,x86_64 for universal)
-->
<installer-gui-script minSpecVersion="2">
<title>Probo Device Posture Agent @@VERSION@@</title>
@@ -50,7 +51,7 @@
<choice id="default"/>
<choice id="com.getprobo.agent"
title="Probo Device Posture Agent"
description="Installs probo-agent to /usr/local/bin and the launchd unit to /Library/LaunchDaemons.">
description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment.">
<pkg-ref id="com.getprobo.agent"/>
</choice>
</installer-gui-script>

View File

@@ -5,16 +5,34 @@
#
# Required arguments:
# --binary PATH Path to a compiled probo-agent binary.
# --arch ARCH Target architecture: amd64 or arm64.
# --arch ARCH Target architecture: amd64, arm64, or universal.
# --version VER Agent version, e.g. 0.1.0. Defaults to the
# content of cmd/probo-agent/VERSION.
# --output PATH Output .pkg path. Defaults to
# dist/probo-agent_${VER}_${OS}.pkg.
# dist/probo-agent_${VER}_darwin_${ARCH}.pkg.
#
# The resulting flat distribution package is unsigned. Apple
# Developer ID signing + notarization are out of scope for this
# script; consumers can chain `productsign` and `xcrun notarytool`
# afterwards.
# Optional environment variables (auditor-mode compatible):
# CODESIGN_IDENTITY Developer ID Application identity. When
# set, signs the agent binary and Probo
# Agent.app with hardened runtime before
# packaging.
# INSTALLER_IDENTITY Developer ID Installer identity. When
# set, passes --sign to productbuild.
# APPLE_ID Apple ID for notarytool store-credentials.
# APPLE_ID_PASSWORD App-specific password; used only to
# populate a keychain profile (not passed
# to long-lived notarytool submit).
# APPLE_TEAM_ID Apple Developer Team ID.
# NOTARYTOOL_KEYCHAIN_PROFILE Existing notarytool keychain profile.
# Defaults to probo-agent-notary when
# storing from APPLE_ID / APPLE_ID_PASSWORD.
#
# Notarization is enabled when NOTARYTOOL_KEYCHAIN_PROFILE is set, or
# when APPLE_ID and APPLE_ID_PASSWORD are both set (APPLE_TEAM_ID also
# required). CODESIGN_IDENTITY and INSTALLER_IDENTITY are then required.
# The script stores credentials into the keychain profile when a
# password is provided, then notarizes and staples the .app before
# packaging and the signed .pkg via --keychain-profile.
#
# Must run on macOS: pkgbuild, productbuild, and swift build are
# Apple-only tools. The build also compiles Probo Agent.app (the
@@ -30,6 +48,12 @@ ARCH=""
VERSION=""
OUTPUT=""
IDENTIFIER="com.getprobo.agent"
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
APPLE_ID="${APPLE_ID:-}"
APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}"
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-}"
usage() {
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
@@ -52,17 +76,60 @@ if [ -z "${BINARY}" ] || [ ! -x "${BINARY}" ]; then
exit 2
fi
case "${ARCH}" in
amd64) PKG_ARCH="x86_64" ;;
arm64) PKG_ARCH="arm64" ;;
"") echo "error: --arch (amd64|arm64) is required" >&2; exit 2 ;;
*) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;;
amd64)
PKG_ARCH="x86_64"
HOST_ARCHS="x86_64"
OUTPUT_ARCH="x86_64"
;;
arm64)
PKG_ARCH="arm64"
HOST_ARCHS="arm64"
OUTPUT_ARCH="arm64"
;;
universal)
PKG_ARCH="arm64"
HOST_ARCHS="arm64,x86_64"
OUTPUT_ARCH="universal"
;;
"")
echo "error: --arch (amd64|arm64|universal) is required" >&2
exit 2
;;
*)
echo "error: unsupported --arch '${ARCH}' (want amd64, arm64, or universal)" >&2
exit 2
;;
esac
# --arch universal advertises both hostArchitectures in Distribution.xml.
# Refuse a single-slice binary so Installer cannot install on a CPU the
# agent cannot run on.
if [ "${ARCH}" = "universal" ]; then
if ! command -v lipo >/dev/null 2>&1; then
echo "error: lipo is required to validate a universal --binary (run on macOS)" >&2
exit 1
fi
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
has_arm64=false
has_x86_64=false
for arch_slice in ${BINARY_ARCHS}; do
case "${arch_slice}" in
arm64) has_arm64=true ;;
x86_64) has_x86_64=true ;;
esac
done
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
exit 2
fi
fi
if [ -z "${VERSION}" ]; then
VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")"
fi
if [ -z "${OUTPUT}" ]; then
mkdir -p "${REPO_ROOT}/dist"
OUTPUT="${REPO_ROOT}/dist/probo-agent_${VERSION}_darwin_${PKG_ARCH}.pkg"
OUTPUT="${REPO_ROOT}/dist/probo-agent_${VERSION}_darwin_${OUTPUT_ARCH}.pkg"
fi
if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null 2>&1; then
@@ -74,6 +141,98 @@ if ! command -v swift >/dev/null 2>&1; then
exit 1
fi
notarize_enabled=false
if [ -n "${NOTARYTOOL_KEYCHAIN_PROFILE}" ]; then
notarize_enabled=true
elif [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ] && [ -n "${APPLE_TEAM_ID}" ]; then
NOTARYTOOL_KEYCHAIN_PROFILE="probo-agent-notary"
notarize_enabled=true
fi
if [ "${notarize_enabled}" = true ]; then
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: notarization requires CODESIGN_IDENTITY" >&2
exit 2
fi
if [ -z "${INSTALLER_IDENTITY}" ]; then
echo "error: notarization requires INSTALLER_IDENTITY" >&2
exit 2
fi
fi
sign_macho() {
local path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then
return 0
fi
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${path}"
codesign --verify --verbose=2 "${path}"
}
sign_app_bundle() {
local app_path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then
return 0
fi
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${app_path}/Contents/MacOS/probo-agent-url-handler"
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${app_path}"
codesign --verify --verbose=2 "${app_path}"
}
ensure_notarytool_credentials() {
if [ -z "${APPLE_ID_PASSWORD}" ]; then
return 0
fi
if [ -z "${APPLE_ID}" ]; then
echo "error: APPLE_ID_PASSWORD requires APPLE_ID to store notarytool credentials" >&2
exit 2
fi
# Password appears on argv only for this short-lived store. Submits
# use --keychain-profile so concurrent processes cannot read it.
xcrun notarytool store-credentials "${NOTARYTOOL_KEYCHAIN_PROFILE}" \
--apple-id "${APPLE_ID}" \
--password "${APPLE_ID_PASSWORD}" \
--team-id "${APPLE_TEAM_ID}"
}
notarytool_submit() {
local path="$1"
xcrun notarytool submit "${path}" \
--keychain-profile "${NOTARYTOOL_KEYCHAIN_PROFILE}" \
--wait
}
notarize_and_staple_app() {
local app_path="$1"
local zip_path
zip_path="${STAGE}/probo-agent-app.zip"
ditto -c -k --keepParent "${app_path}" "${zip_path}"
notarytool_submit "${zip_path}"
rm -f "${zip_path}"
xcrun stapler staple "${app_path}"
}
notarize_and_staple_pkg() {
local pkg_path="$1"
notarytool_submit "${pkg_path}"
xcrun stapler staple "${pkg_path}"
}
STAGE="$(mktemp -d -t probo-agent-pkg)"
trap 'rm -rf "${STAGE}"' EXIT
@@ -83,6 +242,7 @@ RESOURCES="${STAGE}/Resources"
mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}"
install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent"
sign_macho "${PAYLOAD}/usr/local/bin/probo-agent"
mkdir -p "${PAYLOAD}/Applications"
"${SCRIPT_DIR}/enroll-ui/build-app.sh" \
@@ -90,11 +250,34 @@ mkdir -p "${PAYLOAD}/Applications"
--version "${VERSION}" \
--output "${PAYLOAD}/Applications"
install -m 0755 "${SCRIPT_DIR}/scripts/postinstall" "${SCRIPTS}/postinstall"
APP_PATH="${PAYLOAD}/Applications/Probo Agent.app"
sign_app_bundle "${APP_PATH}"
cp "${SCRIPT_DIR}/Resources/welcome.html" "${RESOURCES}/welcome.html"
cp "${SCRIPT_DIR}/Resources/conclusion.html" "${RESOURCES}/conclusion.html"
cp "${REPO_ROOT}/LICENSE" "${RESOURCES}/license.txt"
if [ "${notarize_enabled}" = true ]; then
ensure_notarytool_credentials
echo "Notarizing Probo Agent.app before packaging..."
notarize_and_staple_app "${APP_PATH}"
fi
# Avoid AppleDouble (._*) and resource-fork noise in the package.
export COPYFILE_DISABLE=1
# ditto --norsrc/--noextattr copies without resource forks / xattrs.
ditto --norsrc --noextattr "${SCRIPT_DIR}/scripts/preinstall" "${SCRIPTS}/preinstall"
ditto --norsrc --noextattr "${SCRIPT_DIR}/scripts/postinstall" "${SCRIPTS}/postinstall"
ditto --norsrc --noextattr \
"${REPO_ROOT}/pkg/deviceagent/tray/launchagent.plist.tmpl" \
"${SCRIPTS}/launchagent.plist.tmpl"
chmod 0755 "${SCRIPTS}/preinstall" "${SCRIPTS}/postinstall"
chmod 0644 "${SCRIPTS}/launchagent.plist.tmpl"
ditto --norsrc --noextattr "${SCRIPT_DIR}/Resources/welcome.html" "${RESOURCES}/welcome.html"
ditto --norsrc --noextattr "${SCRIPT_DIR}/Resources/conclusion.html" "${RESOURCES}/conclusion.html"
ditto --norsrc --noextattr "${REPO_ROOT}/LICENSE" "${RESOURCES}/license.txt"
# Strip any xattrs that tools may have reattached (codesign, etc.).
xattr -cr "${PAYLOAD}" "${SCRIPTS}" "${RESOURCES}" 2>/dev/null || true
find "${PAYLOAD}" "${SCRIPTS}" "${RESOURCES}" -name '._*' -delete 2>/dev/null || true
# Component package: payload + scripts only.
COMPONENT_PKG="${STAGE}/probo-agent-component.pkg"
@@ -106,19 +289,63 @@ pkgbuild \
--install-location "/" \
"${COMPONENT_PKG}"
# pkgbuild records protected com.apple.provenance xattrs as empty
# AppleDouble (._*) Bom entries. Rewrite the Bom with mkbom so the
# installer does not lay down those stubs next to real files.
rewrite_component_bom() {
local pkg="$1"
local expand_dir root_dir flat_pkg
expand_dir="${STAGE}/component-expand"
root_dir="${STAGE}/component-root"
flat_pkg="${STAGE}/probo-agent-component-clean.pkg"
rm -rf "${expand_dir}" "${root_dir}" "${flat_pkg}"
# pkgutil --expand creates the destination directory itself.
pkgutil --expand "${pkg}" "${expand_dir}"
find "${expand_dir}/Scripts" -name '._*' -delete 2>/dev/null || true
mkdir -p "${root_dir}"
(
cd "${root_dir}"
gzip -dc "${expand_dir}/Payload" | cpio -idmu 2>/dev/null
)
find "${root_dir}" -name '._*' -delete 2>/dev/null || true
mkbom "${root_dir}" "${expand_dir}/Bom"
if lsbom "${expand_dir}/Bom" | grep -q '/\._'; then
echo "error: rewritten Bom still contains AppleDouble entries" >&2
return 1
fi
pkgutil --flatten "${expand_dir}" "${flat_pkg}"
mv "${flat_pkg}" "${pkg}"
}
rewrite_component_bom "${COMPONENT_PKG}"
# Render Distribution.xml from its template.
DISTRIBUTION="${STAGE}/Distribution.xml"
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
-e "s|@@HOST_ARCHS@@|${PKG_ARCH}|g" \
-e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
mkdir -p "$(dirname "${OUTPUT}")"
productbuild \
--distribution "${DISTRIBUTION}" \
--package-path "${STAGE}" \
--resources "${RESOURCES}" \
"${OUTPUT}"
PRODUCTBUILD_ARGS=(
--distribution "${DISTRIBUTION}"
--package-path "${STAGE}"
--resources "${RESOURCES}"
)
if [ -n "${INSTALLER_IDENTITY}" ]; then
PRODUCTBUILD_ARGS+=(--sign "${INSTALLER_IDENTITY}")
fi
PRODUCTBUILD_ARGS+=("${OUTPUT}")
productbuild "${PRODUCTBUILD_ARGS[@]}"
if [ "${notarize_enabled}" = true ]; then
echo "Notarizing ${OUTPUT}..."
notarize_and_staple_pkg "${OUTPUT}"
fi
echo "Built ${OUTPUT}"

View File

@@ -4,7 +4,7 @@
# the probo:// URL scheme and forwards enrollment links to probo-agent.
#
# Required arguments:
# --arch amd64 or arm64
# --arch amd64, arm64, or universal
# --version Agent version, e.g. 0.1.0
# --output Parent directory; creates "Probo Agent.app" inside it
#
@@ -35,13 +35,17 @@ while [ $# -gt 0 ]; do
done
if [ -z "${ARCH}" ]; then
echo "error: --arch (amd64|arm64) is required" >&2
echo "error: --arch (amd64|arm64|universal) is required" >&2
exit 2
fi
case "${ARCH}" in
amd64) SWIFT_ARCH="x86_64" ;;
arm64) SWIFT_ARCH="arm64" ;;
*) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;;
amd64) SWIFT_ARCH_ARGS=(--arch x86_64) ;;
arm64) SWIFT_ARCH_ARGS=(--arch arm64) ;;
universal) SWIFT_ARCH_ARGS=(--arch arm64 --arch x86_64) ;;
*)
echo "error: unsupported --arch '${ARCH}' (want amd64, arm64, or universal)" >&2
exit 2
;;
esac
if [ -z "${VERSION}" ]; then
echo "error: --version is required" >&2
@@ -61,8 +65,8 @@ BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)"
trap 'rm -rf "${BUILD_DIR}"' EXIT
pushd "${SCRIPT_DIR}" >/dev/null
swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}"
BIN_DIR="$(swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}"
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}"
popd >/dev/null

View File

@@ -21,8 +21,11 @@ BINARY="/usr/local/bin/probo-agent"
STATE_DIR="/var/lib/probo-agent"
RUN_DIR="/var/run/probo-agent"
CONF_FILE="/tmp/probo-agent.conf"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST_NAME="${TRAY_LABEL}.plist"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TRAY_PLIST_TMPL="${SCRIPT_DIR}/launchagent.plist.tmpl"
# Mirror everything to the install log. We keep stdout/stderr open
# too so failures still surface in macOS Installer.app's log pane.
@@ -46,35 +49,33 @@ mkdir -p "${RUN_DIR}"
chown root:wheel "${RUN_DIR}"
chmod 0755 "${RUN_DIR}"
# Render the shared Go LaunchAgent template (pkg/deviceagent/tray/
# launchagent.plist.tmpl) with fixed install paths. Values are
# installer constants, so XML metacharacters are not expected.
render_tray_plist() {
local tmpl="$1"
local out="$2"
sed \
-e "s|{{xml \.Label}}|${TRAY_LABEL}|g" \
-e "s|{{xml \.ExePath}}|${BINARY}|g" \
-e "s|{{xml \.RunDir}}|${RUN_DIR}|g" \
"${tmpl}" > "${out}"
}
register_tray_launchagent() {
local current_user user_uid agents_dir plist_path
agents_dir="/Library/LaunchAgents"
plist_path="${agents_dir}/${TRAY_PLIST_NAME}"
if [ ! -f "${TRAY_PLIST_TMPL}" ]; then
echo "error: tray LaunchAgent template missing at ${TRAY_PLIST_TMPL}"
return 1
fi
mkdir -p "${agents_dir}"
cat > "${plist_path}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${TRAY_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${BINARY}</string>
<string>tray</string>
<string>--run-dir</string>
<string>${RUN_DIR}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
EOF
render_tray_plist "${TRAY_PLIST_TMPL}" "${plist_path}"
chmod 0644 "${plist_path}"
echo "Installed tray LaunchAgent at ${plist_path}."
@@ -154,6 +155,24 @@ register_enrollment_url_scheme() {
echo "Registered probo:// URL scheme."
}
# Restart a previously enrolled LaunchDaemon after upgrades. Preinstall
# boots it out so the binary can be replaced; without /tmp/probo-agent.conf
# enrollment is skipped and nothing else would load it again.
restart_existing_daemon() {
if [ ! -f "${DAEMON_PLIST}" ]; then
return 0
fi
launchctl bootout system "${DAEMON_PLIST}" 2>/dev/null || true
if ! launchctl bootstrap system "${DAEMON_PLIST}"; then
echo "warning: could not start LaunchDaemon at ${DAEMON_PLIST}; it may start after reboot."
return 1
fi
echo "Started LaunchDaemon at ${DAEMON_PLIST}."
return 0
}
# An admin (or MDM) may stage /tmp/probo-agent.conf to drive an
# unattended enrollment. Recognized keys (shell-style):
#
@@ -227,6 +246,7 @@ else
echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon."
fi
restart_existing_daemon
register_tray_launchagent
register_enrollment_url_scheme

59
cmd/probo-agent/installer/macos/scripts/preinstall Normal file → Executable file
View File

@@ -2,8 +2,61 @@
#
# probo-agent macOS PKG preinstall script.
#
# Enrollment is handled by the menu bar helper after installation.
# MDM may still pre-stage /tmp/probo-agent.conf for unattended
# enrollment in postinstall.
# Runs as root before the payload is laid down. Used to stop previous
# LaunchAgent / LaunchDaemon instances so upgrades replace cleanly.
# Failures here are non-fatal: a stuck launchctl must not block install.
set -u
LOG_FILE="/var/log/probo-agent-install.log"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
mkdir -p "$(dirname "${LOG_FILE}")"
exec > >(tee -a "${LOG_FILE}") 2>&1
echo
echo "=== probo-agent preinstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
bootout_tray_for_user() {
local username="$1"
local user_uid
if [ -z "${username}" ] || \
[ "${username}" = "root" ] || \
[ "${username}" = "loginwindow" ]; then
return 0
fi
user_uid="$(id -u "${username}" 2>/dev/null || true)"
if [ -z "${user_uid}" ]; then
return 0
fi
launchctl bootout "gui/${user_uid}/${TRAY_LABEL}" 2>/dev/null || true
}
seen_users=" "
for username in $(users 2>/dev/null || true); do
case "${seen_users}" in
*" ${username} "*) continue ;;
esac
seen_users="${seen_users}${username} "
bootout_tray_for_user "${username}"
done
console_user=$(stat -f "%Su" /dev/console 2>/dev/null || true)
bootout_tray_for_user "${console_user}"
if [ -f "${DAEMON_PLIST}" ]; then
launchctl bootout system "${DAEMON_PLIST}" 2>/dev/null || true
echo "Booted out LaunchDaemon at ${DAEMON_PLIST}."
fi
if [ -f "${TRAY_PLIST}" ]; then
echo "Existing tray LaunchAgent will be replaced by postinstall."
fi
echo "=== preinstall done ==="
exit 0