From afe0c84881ba9d0347e68f1ab4653702226bbb09 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Fri, 17 Jul 2026 13:59:16 +0200 Subject: [PATCH] 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 --- .github/workflows/release-probo-agent.yaml | 100 ++++++- .../installer/macos/Distribution.xml.tmpl | 5 +- cmd/probo-agent/installer/macos/build.sh | 269 ++++++++++++++++-- .../installer/macos/enroll-ui/build-app.sh | 18 +- .../installer/macos/scripts/postinstall | 64 +++-- .../installer/macos/scripts/preinstall | 59 +++- contrib/claude/release/probo-agent.md | 71 ++++- pkg/deviceagent/service/service_darwin.go | 20 +- 8 files changed, 528 insertions(+), 78 deletions(-) mode change 100644 => 100755 cmd/probo-agent/installer/macos/scripts/preinstall diff --git a/.github/workflows/release-probo-agent.yaml b/.github/workflows/release-probo-agent.yaml index 0493935c3..e2be0c427 100644 --- a/.github/workflows/release-probo-agent.yaml +++ b/.github/workflows/release-probo-agent.yaml @@ -20,8 +20,6 @@ jobs: include: - { goos: linux, goarch: amd64 } - { goos: linux, goarch: arm64 } - - { goos: darwin, goarch: amd64 } - - { goos: darwin, goarch: arm64 } - { goos: windows, goarch: amd64 } - { goos: windows, goarch: arm64 } - { goos: freebsd, goarch: amd64 } @@ -53,7 +51,6 @@ jobs: run: | case "$GOOS" in linux) OS="Linux" ;; - darwin) OS="Darwin" ;; windows) OS="Windows" ;; freebsd) OS="Freebsd" ;; esac @@ -82,9 +79,96 @@ jobs: path: "archives/" retention-days: 1 + build-macos: + name: "macos (universal pkg + darwin archives)" + runs-on: "macos-26" + permissions: + contents: "read" + steps: + - uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v6 + with: + submodules: recursive + - uses: "./.github/actions/setup" + with: + node: "false" + - name: "Select Xcode version" + run: | + sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + xcode-select -p + swift --version + - name: "Import signing certificate" + env: + CERTIFICATE_BASE64: "${{ secrets.APPLE_CERTIFICATE }}" + CERTIFICATE_PASSWORD: "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" + KEYCHAIN_PASSWORD: "${{ secrets.KEYCHAIN_PASSWORD }}" + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12 + security import certificate.p12 -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + rm certificate.p12 + - name: "Build CGO darwin binaries and universal pkg" + env: + CGO_ENABLED: "1" + CODESIGN_IDENTITY: "${{ secrets.CODESIGN_IDENTITY }}" + INSTALLER_IDENTITY: "${{ secrets.INSTALLER_IDENTITY }}" + APPLE_ID: "${{ secrets.APPLE_ID }}" + APPLE_ID_PASSWORD: "${{ secrets.APPLE_ID_PASSWORD }}" + APPLE_TEAM_ID: "${{ secrets.APPLE_TEAM_ID }}" + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME##*/v}" + LDFLAGS="-s -w -X 'main.version=${VERSION}'" + + mkdir -p dist archives staging + + GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" \ + -gcflags="-e" -o dist/probo-agent_arm64 ./cmd/probo-agent + GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" \ + -gcflags="-e" -o dist/probo-agent_amd64 ./cmd/probo-agent + + lipo -create \ + dist/probo-agent_arm64 \ + dist/probo-agent_amd64 \ + -output dist/probo-agent_universal + + for pair in "arm64:arm64" "amd64:x86_64"; do + GOARCH="${pair%%:*}" + ARCH="${pair##*:}" + AGENT_DIR="probo-agent_Darwin_${ARCH}" + mkdir -p "staging/${AGENT_DIR}" + cp "dist/probo-agent_${GOARCH}" "staging/${AGENT_DIR}/probo-agent" + cp README.md LICENSE "staging/${AGENT_DIR}/" + if [ -f cmd/probo-agent/CHANGELOG.md ]; then + cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/" + fi + tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}" + done + + chmod +x cmd/probo-agent/installer/macos/build.sh \ + cmd/probo-agent/installer/macos/enroll-ui/build-app.sh \ + cmd/probo-agent/installer/macos/scripts/preinstall \ + cmd/probo-agent/installer/macos/scripts/postinstall + + cmd/probo-agent/installer/macos/build.sh \ + --binary dist/probo-agent_universal \ + --arch universal \ + --version "${VERSION}" \ + --output "archives/probo-agent_${VERSION}_darwin_universal.pkg" + - uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7 + with: + name: "archive-darwin-universal" + path: "archives/" + retention-days: 1 + github-release: name: "github-release" - needs: [build-binary] + needs: [build-binary, build-macos] runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache" permissions: contents: "write" @@ -104,7 +188,7 @@ jobs: - name: "Generate checksums and sign" run: | cd archives - sha256sum *.tar.gz *.zip > checksums.txt + sha256sum *.tar.gz *.zip *.pkg > checksums.txt cosign sign-blob --bundle="checksums.txt.bundle" checksums.txt --yes - name: "Inject release checksums into install.sh" run: | @@ -148,12 +232,12 @@ jobs: - name: "Attest SBOM for archives" uses: "actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e" # v4 with: - subject-path: "archives/*.tar.gz, archives/*.zip" + subject-path: "archives/*.tar.gz, archives/*.zip, archives/*.pkg" sbom-path: "sbom.json" - name: "Attest build provenance for archives" uses: "actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373" # v4 with: - subject-path: "archives/*.tar.gz, archives/*.zip" + subject-path: "archives/*.tar.gz, archives/*.zip, archives/*.pkg" - name: "Extract release notes" run: | VERSION="${GITHUB_REF_NAME##*/v}" @@ -167,7 +251,7 @@ jobs: fi - name: "Create GitHub release" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" run: | PRERELEASE_FLAG="" if echo "${GITHUB_REF_NAME}" | grep -qE '(alpha|beta|rc)'; then diff --git a/cmd/probo-agent/installer/macos/Distribution.xml.tmpl b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl index 1b7f0d530..f5b51b6d5 100644 --- a/cmd/probo-agent/installer/macos/Distribution.xml.tmpl +++ b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl @@ -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) --> Probo Device Posture Agent @@VERSION@@ @@ -50,7 +51,7 @@ + description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment."> diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh index 966d95f16..2697b6f1e 100755 --- a/cmd/probo-agent/installer/macos/build.sh +++ b/cmd/probo-agent/installer/macos/build.sh @@ -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}" diff --git a/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh b/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh index 4886d68a8..54364d7f6 100755 --- a/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh +++ b/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh @@ -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 diff --git a/cmd/probo-agent/installer/macos/scripts/postinstall b/cmd/probo-agent/installer/macos/scripts/postinstall index 62de0c137..459324a12 100755 --- a/cmd/probo-agent/installer/macos/scripts/postinstall +++ b/cmd/probo-agent/installer/macos/scripts/postinstall @@ -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}" < - - - - Label - ${TRAY_LABEL} - ProgramArguments - - ${BINARY} - tray - --run-dir - ${RUN_DIR} - - RunAtLoad - - KeepAlive - - - -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 diff --git a/cmd/probo-agent/installer/macos/scripts/preinstall b/cmd/probo-agent/installer/macos/scripts/preinstall old mode 100644 new mode 100755 index ecf39c3a7..ad9f70190 --- a/cmd/probo-agent/installer/macos/scripts/preinstall +++ b/cmd/probo-agent/installer/macos/scripts/preinstall @@ -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 diff --git a/contrib/claude/release/probo-agent.md b/contrib/claude/release/probo-agent.md index 598233cb9..80ed51c4a 100644 --- a/contrib/claude/release/probo-agent.md +++ b/contrib/claude/release/probo-agent.md @@ -34,9 +34,11 @@ stay pure Go (no tray). ## Notes -CI builds binaries for 8 OS/arch targets (linux, darwin, and windows on -amd64 and arm64; freebsd on amd64 and arm64), publishes a GitHub -Release with signed checksums, SBOM, and build attestations. The agent +CI builds binaries for linux, windows, and freebsd (amd64/arm64) on +Linux runners, and builds **CGO-enabled** darwin archives plus a +signed/notarized **universal** `.pkg` on a macOS runner. The GitHub +Release includes those archives, `probo-agent_*_darwin_universal.pkg`, +`install.sh`, signed checksums, SBOM, and build attestations. The agent auto-update path downloads the matching archive plus `checksums.txt` and verifies the cosign bundle before installing. @@ -45,14 +47,65 @@ Linux and FreeBSD use `probo-agent install --server … --enrollment-token …` from the shell, or the curl-to-sh installer documented below. Windows release binaries are cross-compiled from Linux with MinGW (CGO). macOS release binaries and -`.pkg` installers must be built on macOS with `CGO_ENABLED=1`. +the `.pkg` are built on macOS with `CGO_ENABLED=1`. -macOS `.pkg` installers are built locally with +### macOS `.pkg` (MDM / GUI install) + +Release and local builds use `cmd/probo-agent/installer/macos/build.sh` (requires macOS, a -pre-built binary, and the Swift toolchain). The script also compiles -`Probo Agent.app` — the headless `probo://` URL handler installed to -`/Applications` — from `cmd/probo-agent/installer/macos/enroll-ui/`. -They are not part of the GitHub Release workflow yet. +pre-built binary — preferably universal via `lipo` — and the Swift +toolchain). The script compiles `Probo Agent.app` (the headless +`probo://` URL handler) from +`cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and app +when `CODESIGN_IDENTITY` is set, signs the product with +`INSTALLER_IDENTITY`, and notarizes/staples when +`NOTARYTOOL_KEYCHAIN_PROFILE` is set, or when `APPLE_ID`, +`APPLE_ID_PASSWORD`, and `APPLE_TEAM_ID` are set (password is stored +into a keychain profile; submits use `--keychain-profile` so the secret +is not on `notarytool submit` argv). + +```shell +# Local unsigned universal pkg (example) +GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o dist/probo-agent_arm64 ./cmd/probo-agent +GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o dist/probo-agent_amd64 ./cmd/probo-agent +lipo -create dist/probo-agent_arm64 dist/probo-agent_amd64 -output dist/probo-agent_universal +cmd/probo-agent/installer/macos/build.sh \ + --binary dist/probo-agent_universal \ + --arch universal \ + --version "$(cat cmd/probo-agent/VERSION)" +``` + +PKG postinstall always installs the global tray LaunchAgent and +registers `probo://`. The LaunchDaemon for `probo-agent run` is created +only after enrollment (`probo-agent install`, deep link, or MDM +`/tmp/probo-agent.conf`). + +### Apple signing secrets (GitHub) + +The `build-macos` job in `release-probo-agent.yaml` expects the same +secret names as the auditor-mode release workflow. Configure these on +the probo GitHub repository (or org) before tagging a release: + +| Secret | Purpose | +|--------|---------| +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` (Developer ID) | +| `APPLE_CERTIFICATE_PASSWORD` | `.p12` password | +| `KEYCHAIN_PASSWORD` | Ephemeral CI keychain password | +| `CODESIGN_IDENTITY` | e.g. `Developer ID Application: Probo Inc (TEAMID)` | +| `INSTALLER_IDENTITY` | e.g. `Developer ID Installer: Probo Inc (TEAMID)` | +| `APPLE_ID` | Apple ID email for `notarytool store-credentials` | +| `APPLE_ID_PASSWORD` | App-specific password (stored into a keychain profile; not passed to `submit`) | +| `APPLE_TEAM_ID` | 10-character Team ID | + +Local notarization can reuse a pre-stored profile instead of putting the +password in the environment: + +```shell +xcrun notarytool store-credentials probo-agent-notary \ + --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" +# prompts for the app-specific password once +export NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary +``` Windows enrollment is browser-driven: the console issues a `probo://enroll?server=...&token=...` deep link handled by diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go index d22396ec3..9fb7f9f4a 100644 --- a/pkg/deviceagent/service/service_darwin.go +++ b/pkg/deviceagent/service/service_darwin.go @@ -31,7 +31,9 @@ import ( "text/template" ) -const plistPath = "/Library/LaunchDaemons/com.getprobo.agent.plist" +const ( + plistPath = "/Library/LaunchDaemons/com.probo.agent.plist" +) const launchdPlistTmpl = `