diff --git a/.github/workflows/make.yaml b/.github/workflows/make.yaml index f3b0ead1f..881500fce 100644 --- a/.github/workflows/make.yaml +++ b/.github/workflows/make.yaml @@ -380,6 +380,41 @@ jobs: reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true done + lint-swift: + name: "lint-swift" + runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache" + permissions: + contents: "read" + pull-requests: "write" + steps: + - uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" # v6 + with: + submodules: recursive + - uses: "runs-on/action@d141ef83eb66d096ce8afc767e09115a65c63b60" # v2 + - uses: "swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a" # v2.4.0 + with: + swift-version: "6.0" + - uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1.5.0 + - name: "Install SwiftLint" + run: | + curl -sL "https://github.com/realm/SwiftLint/releases/download/0.65.0/swiftlint_linux_amd64.zip" -o /tmp/swiftlint.zip + sudo unzip -o /tmp/swiftlint.zip -d /usr/local/bin + sudo chmod +x /usr/local/bin/swiftlint + swiftlint version + - name: "Run swift format" + run: | + sources="$(find cmd/probo-agent/installer/macos/enroll-ui \( -name '*.swift' ! -name '*.generated.swift' ! -path '*/.build/*' \) | sort)" + swift format lint --configuration .swift-format --strict --parallel $sources + - name: "Run SwiftLint" + run: swiftlint lint --strict --config .swiftlint.yml --cache-path /tmp/swiftlint-cache + - name: "Annotate PR with SwiftLint findings" + if: failure() && github.event_name == 'pull_request' + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + swiftlint lint --config .swiftlint.yml --cache-path /tmp/swiftlint-cache 2>&1 | \ + reviewdog -f=swiftlint -reporter=github-pr-review -filter-mode=nofilter -name="swiftlint" || true + test: name: "test" runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache" diff --git a/.gitignore b/.gitignore index 3519bdca5..98d31d16b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bin/ node_modules/ +.cache/ .turbo .vscode .cursor/* diff --git a/.swift-format b/.swift-format new file mode 100644 index 000000000..ff9a9021b --- /dev/null +++ b/.swift-format @@ -0,0 +1,28 @@ +{ + "version": 1, + "lineLength": 100, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineBreakBetweenDeclarationAttributes": false, + "prioritizeKeepingFunctionOutputTogether": false, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "spacesAroundRangeFormationOperators": false, + "spacesBeforeEndOfLineComments": 2, + "indentBlankLines": false, + "multiElementCollectionTrailingCommas": true, + "reflowMultilineStringLiterals": "never", + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "orderedImports": { + "includeConditionalImports": false + } +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..9fa313669 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,30 @@ +# Small opt-in set (mirrors .golangci.yml default: none + few linters). +# Syntax-only rules so lint runs on Linux without SourceKit/macOS SDKs. +only_rules: + - duplicate_imports + - empty_count + - empty_parameters + - empty_string + - force_cast + - force_try + - force_unwrapping + - orphaned_doc_comment + - redundant_nil_coalescing + - statement_position + - trailing_newline + - trailing_whitespace + - unused_closure_parameter + - unused_optional_binding + - vertical_whitespace + +included: + - cmd/probo-agent/installer/macos/enroll-ui + +excluded: + - cmd/probo-agent/installer/macos/enroll-ui/.build + - "**/*.generated.swift" + - "**/*.tmpl" + +force_cast: error +force_try: error +force_unwrapping: error diff --git a/GNUmakefile b/GNUmakefile index 7b03e8526..f265843f8 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -17,6 +17,13 @@ SYFT ?= syft TAIL ?= tail ECHO ?= echo GOLINTCMD ?= golangci-lint +SWIFTLINTCMD ?= swiftlint +SWIFTCMD ?= swift +SWIFT_ENROLL_UI ?= cmd/probo-agent/installer/macos/enroll-ui +SWIFT_FORMAT_CONFIG ?= .swift-format +SWIFTLINT_CONFIG ?= .swiftlint.yml + +swift_sources = $(shell find $(SWIFT_ENROLL_UI) \( -name '*.swift' ! -name '*.generated.swift' ! -path '*/.build/*' \) | sort) DOCKER_BUILD_FLAGS?= DOCKER_BUILD= DOCKER_BUILDKIT=1 $(DOCKER) build $(DOCKER_BUILD_FLAGS) @@ -90,15 +97,12 @@ PROBOCTL_SRC= cmd/proboctl/main.go PROBO_AGENT_BIN= bin/probo-agent PROBO_AGENT_SRC= ./cmd/probo-agent -# Menu bar / tray enrollment is macOS and Windows only; those hosts need CGO. +# Menu bar / tray enrollment is macOS and Windows; only macOS needs CGO. PROBO_AGENT_TARGET_OS= $(if $(GOOS),$(GOOS),$(shell $(GO) env GOOS)) PROBO_AGENT_CGO= 0 ifeq ($(PROBO_AGENT_TARGET_OS),darwin) PROBO_AGENT_CGO= 1 endif -ifeq ($(PROBO_AGENT_TARGET_OS),windows) -PROBO_AGENT_CGO= 1 -endif ifdef WITH_APPS GENERATED += relay @@ -120,6 +124,19 @@ lint-go: vet go-fmt go-fix go-lint lint-js: $(NPM) run lint +.PHONY: lint-swift +lint-swift: swift-fmt swift-lint ## Lint Swift enroll-ui (format + SwiftLint) + +.PHONY: swift-fmt +swift-fmt: ## Check Swift formatting with swift format + @command -v $(SWIFTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTCMD)' not found; install the Swift toolchain (Xcode on macOS)"; exit 1; } + $(SWIFTCMD) format lint --configuration $(SWIFT_FORMAT_CONFIG) --strict --parallel $(swift_sources) + +.PHONY: swift-lint +swift-lint: ## Lint Swift with SwiftLint + @command -v $(SWIFTLINTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTLINTCMD)' not found; install SwiftLint (e.g. brew install swiftlint)"; exit 1; } + $(SWIFTLINTCMD) lint --strict --config $(SWIFTLINT_CONFIG) --cache-path .cache/swiftlint + .PHONY: vet vet: generate embed $(GO_VET) ./... @@ -393,6 +410,14 @@ fmt: fmt-go ## Format Go code fmt-go: ## Format Go code go fmt ./... +.PHONY: fmt-swift +fmt-swift: ## Format Swift enroll-ui sources + @command -v $(SWIFTCMD) >/dev/null 2>&1 || { echo "error: '$(SWIFTCMD)' not found; install the Swift toolchain (Xcode on macOS)"; exit 1; } + $(SWIFTCMD) format --configuration $(SWIFT_FORMAT_CONFIG) --in-place --parallel $(swift_sources) + @if command -v $(SWIFTLINTCMD) >/dev/null 2>&1; then \ + $(SWIFTLINTCMD) lint --fix --config $(SWIFTLINT_CONFIG) --cache-path .cache/swiftlint; \ + fi + .PHONY: clean clean: ## Clean the project (node_modules and build artifacts) $(RM) -rf bin/* diff --git a/cmd/probo-agent/CHANGELOG.md b/cmd/probo-agent/CHANGELOG.md index 189a9eddc..68ac04218 100644 --- a/cmd/probo-agent/CHANGELOG.md +++ b/cmd/probo-agent/CHANGELOG.md @@ -5,6 +5,26 @@ documented in this file. ## Unreleased +### Added + +- macOS privileged helper (`com.probo.agent.helper`) embedded in + `Probo Agent.app` and installed by PKG postinstall for XPC-driven + browser enrollment (no SMJobBless / admin prompt on enroll). +- Hidden `probo-agent enroll-url --preflight` JSON output for the URL handler. +- `make -C cmd/probo-agent install|uninstall|clean` for local macOS PKG + test loops (install tears down leftovers first). + +### Changed + +- Browser enrollment via `Probo Agent.app` uses HelperClient + XPC only + (osascript elevation and enroll-time SMJobBless removed). +- macOS PKG / app builds require `CODESIGN_IDENTITY` and `APPLE_TEAM_ID`. +- CLI `enroll-url` on macOS refuses elevation; use the signed app deeplink + or `sudo probo-agent install`. +- macOS `probo-agent uninstall` requires root (`sudo`). +- PKG preinstall removes stale privileged helper files on upgrade; + postinstall reinstalls the helper as root. + ## [0.1.1] - 2026-06-11 ### Changed diff --git a/cmd/probo-agent/GNUmakefile b/cmd/probo-agent/GNUmakefile index 04c886956..cc6b329d3 100644 --- a/cmd/probo-agent/GNUmakefile +++ b/cmd/probo-agent/GNUmakefile @@ -1,15 +1,37 @@ +# Local build / install helpers for probo-agent. +# +# macOS (primary for browser-enroll testing): +# make install build signed PKG, uninstall leftovers, install PKG +# make uninstall remove all system artifacts (idempotent) +# make clean uninstall + wipe local build caches +# +# Requires on Darwin: CODESIGN_IDENTITY, APPLE_TEAM_ID +# Optional: INSTALLER_IDENTITY; notarize via APPLE_ID+APPLE_ID_PASSWORD +# (stored into a keychain profile; submit uses --keychain-profile) +# +# CLI-only (no app / helper), any Unix: +# make install-cli +# make run + CP ?= cp MKDIR ?= mkdir -p +RMRF ?= rm -rf SUDO ?= sudo +REPO_ROOT= $(abspath ../..) +PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent +VERSION= $(shell cat VERSION) + STATE_DIR= $(HOME)/.local/share/probo-agent-dev CACHE_ROOT= $(HOME)/.cache/probo-agent-dev DEV_TAG= probo-agent/dev BINARY= /usr/local/bin/probo-agent RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG) INSTALL_SCRIPT= installer/install.sh -REPO_ROOT= ../.. -PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent +MACOS_BUILD_SCRIPT= installer/macos/build.sh +MACOS_UNINSTALL_SCRIPT= installer/macos/uninstall.sh +MACOS_REINSTALL_SCRIPT= installer/macos/reinstall.sh +ENROLL_UI_BUILD= installer/macos/enroll-ui/.build UNAME_S:= $(shell uname -s) UNAME_M:= $(shell uname -m) @@ -26,12 +48,19 @@ endif ifeq ($(UNAME_M),x86_64) ARCH_LABEL= x86_64 +BUILD_ARCH= amd64 +else ifeq ($(UNAME_M),amd64) +ARCH_LABEL= x86_64 +BUILD_ARCH= amd64 else ifeq ($(UNAME_M),arm64) ARCH_LABEL= arm64 +BUILD_ARCH= arm64 else ifeq ($(UNAME_M),aarch64) ARCH_LABEL= arm64 +BUILD_ARCH= arm64 else ARCH_LABEL= +BUILD_ARCH= endif AGENT_DIR= probo-agent_$(OS_LABEL)_$(ARCH_LABEL) @@ -40,26 +69,104 @@ ARCHIVE_PATH= $(RELEASE_DIR)/$(ARCHIVE_NAME) STAGING_DIR= $(CACHE_ROOT)/staging/$(AGENT_DIR) BUILD_BINARY= $(STAGING_DIR)/probo-agent +PKG= $(REPO_ROOT)/dist/probo-agent_$(VERSION)_darwin_$(ARCH_LABEL).pkg + INSTALL_ARGS?= --skip-service --dir "$(STATE_DIR)" INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \ - PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \ - PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \ - PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \ - PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \ - PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)" + PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \ + PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \ + PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \ + PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \ + PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)" -.PHONY: all install run clean -all: install +.PHONY: help all pkg install uninstall clean clean-build install-cli run +.PHONY: $(PROBO_AGENT_BIN) -install: $(ARCHIVE_PATH) - $(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS) +all: help -run: +help: ## Show targets + @printf '%s\n' \ + 'Targets:' \ + ' install macOS: build signed PKG, wipe previous install, install PKG' \ + ' other: same as install-cli' \ + ' uninstall Remove system install (macOS: full PKG/helper/app teardown)' \ + ' clean uninstall + remove local build caches' \ + ' clean-build Remove local caches only (no sudo)' \ + ' pkg Build signed macOS PKG to dist/ (Darwin only)' \ + ' install-cli Install binary via installer/install.sh (dev state dir)' \ + ' run Foreground run against install-cli state dir' \ + '' \ + 'Darwin install requires CODESIGN_IDENTITY and APPLE_TEAM_ID.' + +# --- primary local test loop ------------------------------------------------- + +install: ## Install for local testing (PKG on macOS) +ifeq ($(UNAME_S),Darwin) + $(MAKE) pkg + $(SUDO) "$(MACOS_REINSTALL_SCRIPT)" "$(PKG)" +else + $(MAKE) install-cli +endif + +uninstall: ## Remove all system artifacts +ifeq ($(UNAME_S),Darwin) + $(SUDO) "$(MACOS_UNINSTALL_SCRIPT)" +else + @if [ -x "$(BINARY)" ]; then \ + $(SUDO) "$(BINARY)" uninstall || true; \ + fi + $(SUDO) $(RMRF) "$(BINARY)" +endif + +clean: uninstall clean-build ## uninstall + wipe build caches + +clean-build: ## Wipe local build caches (no sudo) + $(RMRF) "$(STATE_DIR)" "$(CACHE_ROOT)" "$(ENROLL_UI_BUILD)" + @# Unquoted globs so the shell can expand dist artifacts. + -$(RMRF) $(REPO_ROOT)/dist/probo-agent_*.pkg + @# Native binary under /usr/local is owned by root after install; leave it + @# to `uninstall`. Never remove it here without sudo. + +# --- macOS PKG --------------------------------------------------------------- + +pkg: $(PKG) ## Build signed .pkg into dist/ + +$(PKG): $(PROBO_AGENT_BIN) +ifeq ($(UNAME_S),Darwin) + @if [ -z "$(CODESIGN_IDENTITY)" ]; then \ + echo 'error: CODESIGN_IDENTITY is required' >&2; exit 2; \ + fi + @if [ -z "$(APPLE_TEAM_ID)" ]; then \ + echo 'error: APPLE_TEAM_ID is required' >&2; exit 2; \ + fi + @if [ -z "$(BUILD_ARCH)" ]; then \ + echo 'error: unsupported arch $(UNAME_M)' >&2; exit 2; \ + fi + @$(MKDIR) "$(dir $(PKG))" + @CODESIGN_IDENTITY="$(CODESIGN_IDENTITY)" \ + APPLE_TEAM_ID="$(APPLE_TEAM_ID)" \ + INSTALLER_IDENTITY="$(INSTALLER_IDENTITY)" \ + APPLE_ID="$(APPLE_ID)" \ + APPLE_ID_PASSWORD="$(APPLE_ID_PASSWORD)" \ + NOTARYTOOL_KEYCHAIN_PROFILE="$(NOTARYTOOL_KEYCHAIN_PROFILE)" \ + sh "$(MACOS_BUILD_SCRIPT)" \ + --binary "$(PROBO_AGENT_BIN)" \ + --arch "$(BUILD_ARCH)" \ + --version "$(VERSION)" \ + --output "$(PKG)" +else + @echo 'error: pkg target is macOS-only' >&2 + @exit 1 +endif + +# --- CLI-only (no Probo Agent.app / helper) ---------------------------------- + +install-cli: $(ARCHIVE_PATH) ## Install binary only via install.sh + @$(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS) + +run: ## Run agent in foreground (install-cli state dir) $(SUDO) "$(BINARY)" run --dir "$(STATE_DIR)" -clean: - rm -rf "$(STATE_DIR)" "$(CACHE_ROOT)" "$(BINARY)" - $(ARCHIVE_PATH): $(BUILD_BINARY) $(MKDIR) "$(RELEASE_DIR)" tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)" diff --git a/cmd/probo-agent/enroll_url_test.go b/cmd/probo-agent/enroll_url_test.go new file mode 100644 index 000000000..1360e63d3 --- /dev/null +++ b/cmd/probo-agent/enroll_url_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEnrollURLPreflight(t *testing.T) { + t.Parallel() + + root := newRootCmd() + root.SetArgs([]string{ + "enroll-url", + "--preflight", + "--dir", t.TempDir(), + "probo://enroll?server=https%3A%2F%2Fexample.com&token=abc123", + }) + + var stdout bytes.Buffer + + root.SetOut(&stdout) + root.SetErr(&stdout) + + err := root.Execute() + require.NoError(t, err) + + var payload enrollPreflightResponse + + err = json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload) + require.NoError(t, err) + require.Equal(t, "https://example.com", payload.Server) + require.Equal(t, "abc123", payload.Token) + require.False(t, payload.AlreadyEnrolled) + require.Contains(t, payload.ConfigDir, "TestEnrollURLPreflight") +} + +func TestWriteEnrollPreflight(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + + err := writeEnrollPreflight( + &stdout, + "https://example.com", + "token-value", + "/var/lib/probo-agent", + true, + ) + require.NoError(t, err) + + var payload enrollPreflightResponse + + err = json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload) + require.NoError(t, err) + require.True(t, payload.AlreadyEnrolled) + require.Equal(t, "token-value", payload.Token) +} diff --git a/cmd/probo-agent/installer/macos/Distribution.xml.tmpl b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl index f5b51b6d5..d4df3185b 100644 --- a/cmd/probo-agent/installer/macos/Distribution.xml.tmpl +++ b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl @@ -9,6 +9,7 @@ @@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) + @@IDENTIFIER@@ package identifier (default com.probo.agent) --> Probo Device Posture Agent @@VERSION@@ @@ -38,20 +39,20 @@ - probo-agent-component.pkg - + - - + diff --git a/cmd/probo-agent/installer/macos/Resources/welcome.html b/cmd/probo-agent/installer/macos/Resources/welcome.html index 3d65c5ab6..ae89f326f 100644 --- a/cmd/probo-agent/installer/macos/Resources/welcome.html +++ b/cmd/probo-agent/installer/macos/Resources/welcome.html @@ -48,6 +48,9 @@
  • Installs Probo Agent.app to /Applications so browser enrollment deep links (probo://) work.
  • +
  • Installs the privileged helper + com.probo.agent.helper so browser enrollment can + finish without a second admin password prompt.
  • Registers the menu bar helper LaunchAgent com.probo.agent.tray in /Library/LaunchAgents.
  • diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh index 2697b6f1e..7cfc818ae 100755 --- a/cmd/probo-agent/installer/macos/build.sh +++ b/cmd/probo-agent/installer/macos/build.sh @@ -11,28 +11,26 @@ # --output PATH Output .pkg path. Defaults to # dist/probo-agent_${VER}_darwin_${ARCH}.pkg. # -# 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. +# Required environment variables: +# CODESIGN_IDENTITY Developer ID Application identity. Signs the +# agent binary, Probo Agent.app, and embedded helper. +# APPLE_TEAM_ID Apple Developer Team ID (helper client requirement). +# +# Optional (auditor-mode compatible): # 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. +# NOTARYTOOL_KEYCHAIN_PROFILE Keychain profile name for store/submit. +# Defaults to probo-agent-notary. # -# 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. +# Notarization is enabled when APPLE_ID and APPLE_ID_PASSWORD are both +# set. INSTALLER_IDENTITY is then required. The script stores credentials +# into the keychain profile, then notarizes and staples the .app before +# packaging and the signed .pkg via --keychain-profile so the password +# is not on submit argv for the long --wait. # # Must run on macOS: pkgbuild, productbuild, and swift build are # Apple-only tools. The build also compiles Probo Agent.app (the @@ -47,13 +45,13 @@ BINARY="" ARCH="" VERSION="" OUTPUT="" -IDENTIFIER="com.getprobo.agent" +IDENTIFIER="com.probo.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:-}" +NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-probo-agent-notary}" usage() { sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" @@ -101,28 +99,42 @@ case "${ARCH}" in ;; esac -# --arch universal advertises both hostArchitectures in Distribution.xml. -# Refuse a single-slice binary so Installer cannot install on a CPU the +# --arch sets hostArchitectures in Distribution.xml. Refuse a binary that +# lacks the advertised slice(s) 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 +if ! command -v lipo >/dev/null 2>&1; then + echo "error: lipo is required to validate --binary architecture (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 +case "${ARCH}" in + amd64) + if [ "${has_x86_64}" != true ]; then + echo "error: --arch amd64 requires a binary with an x86_64 slice (got: ${BINARY_ARCHS})" >&2 + exit 2 + fi + ;; + arm64) + if [ "${has_arm64}" != true ]; then + echo "error: --arch arm64 requires a binary with an arm64 slice (got: ${BINARY_ARCHS})" >&2 + exit 2 + fi + ;; + universal) + 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 + ;; +esac if [ -z "${VERSION}" ]; then VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")" @@ -140,30 +152,26 @@ if ! command -v swift >/dev/null 2>&1; then echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2 exit 1 fi +if [ -z "${CODESIGN_IDENTITY}" ]; then + echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2 + exit 2 +fi +if [ -z "${APPLE_TEAM_ID}" ]; then + echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2 + exit 2 +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" +if [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ]; then 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 +if [ "${notarize_enabled}" = true ] && [ -z "${INSTALLER_IDENTITY}" ]; then + echo "error: notarization requires INSTALLER_IDENTITY" >&2 + exit 2 fi sign_macho() { local path="$1" - if [ -z "${CODESIGN_IDENTITY}" ]; then - return 0 - fi codesign \ --force \ --options runtime \ @@ -175,8 +183,15 @@ sign_macho() { sign_app_bundle() { local app_path="$1" - if [ -z "${CODESIGN_IDENTITY}" ]; then - return 0 + local helper_path="${app_path}/Contents/Library/LaunchServices/com.probo.agent.helper" + if [ -x "${helper_path}" ]; then + codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "${CODESIGN_IDENTITY}" \ + "${helper_path}" + codesign --verify --verbose=2 "${helper_path}" fi codesign \ --force \ @@ -194,11 +209,8 @@ sign_app_bundle() { } 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 + if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_ID_PASSWORD}" ]; then + echo "error: APPLE_ID and APPLE_ID_PASSWORD are required to store notarytool credentials" >&2 exit 2 fi # Password appears on argv only for this short-lived store. Submits @@ -327,6 +339,7 @@ sed \ -e "s|@@VERSION@@|${VERSION}|g" \ -e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \ -e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \ + -e "s|@@IDENTIFIER@@|${IDENTIFIER}|g" \ "${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}" mkdir -p "$(dirname "${OUTPUT}")" diff --git a/cmd/probo-agent/installer/macos/enroll-ui/.gitignore b/cmd/probo-agent/installer/macos/enroll-ui/.gitignore index 2d9f16e2d..cc8601ec8 100644 --- a/cmd/probo-agent/installer/macos/enroll-ui/.gitignore +++ b/cmd/probo-agent/installer/macos/enroll-ui/.gitignore @@ -1,2 +1,6 @@ +# SwiftPM .build/ .swiftpm/ + +# Rendered by build-app.sh from *.tmpl (do not commit) +Shared/*.generated.swift diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/EnrollmentFlow.swift b/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/EnrollmentFlow.swift new file mode 100644 index 000000000..d7c7aa381 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/EnrollmentFlow.swift @@ -0,0 +1,43 @@ +import Foundation +import ProboAgentShared + +public struct EnrollPreflightResult: Decodable { + public let server: String + public let token: String + public let alreadyEnrolled: Bool + public let configDir: String +} + +public enum EnrollmentFlow { + private static let agentExecutablePath = ProboAgentHelperConstants.agentExecutablePath + + public static func runPreflight(rawURL: String) throws -> EnrollPreflightResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: agentExecutablePath) + process.arguments = ["enroll-url", "--preflight", rawURL] + + let output = Pipe() + process.standardOutput = output + process.standardError = output + + try process.run() + let data = try output.fileHandleForReading.readToEnd() ?? Data() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw HelperClientError.operationFailed(process.terminationStatus, message) + } + + return try JSONDecoder().decode(EnrollPreflightResult.self, from: data) + } + + public static func installViaHelper(preflight: EnrollPreflightResult) throws { + try HelperClient.shared.install( + serverURL: preflight.server, + enrollmentToken: preflight.token, + configDir: preflight.configDir + ) + } +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/HelperClient.swift b/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/HelperClient.swift new file mode 100644 index 000000000..6ad8f6dd6 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperClient/HelperClient.swift @@ -0,0 +1,214 @@ +import Foundation +import ProboAgentShared + +public enum HelperClientError: LocalizedError { + case helperNotInstalled + case connectionFailed(String) + case operationFailed(Int32, String?) + + public var errorDescription: String? { + switch self { + case .helperNotInstalled: + return """ + Privileged helper is not installed. Reinstall the Probo Agent \ + package, then try enrollment again. + """ + case .connectionFailed(let message): + return "Cannot connect to privileged helper: \(message)" + case .operationFailed(let code, let message): + if let message, !message.isEmpty { + return message + } + return "Privileged operation failed (exit \(code))." + } + } +} + +/// Once-only sync bridge for an in-flight XPC call. Reply and proxy error +/// handlers both finish here so disconnects unblock waiters immediately. +private final class XPCCallCompletion: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var finished = false + private var error: Error? + + func succeed() { + complete(nil) + } + + func fail(_ error: Error) { + complete(error) + } + + private func complete(_ error: Error?) { + lock.lock() + defer { lock.unlock() } + guard !finished else { return } + finished = true + self.error = error + semaphore.signal() + } + + /// Waits for succeed/fail. Throws the stored error, or `timedOut` on timeout. + func wait(timeout: TimeInterval, timedOut: @autoclosure () -> Error) throws { + if semaphore.wait(timeout: .now() + timeout) == .timedOut { + throw timedOut() + } + if let error { + throw error + } + } + + /// Waits for succeed/fail. Returns `false` on timeout; throws the stored error. + func wait(timeout: TimeInterval) throws -> Bool { + if semaphore.wait(timeout: .now() + timeout) == .timedOut { + return false + } + if let error { + throw error + } + return true + } +} + +final public class HelperClient { + public static let shared = HelperClient() + + /// Serializes public install within this process so privileged helper + /// work never overlaps. Does not coordinate across processes. + private let operationLock = NSLock() + + private init() {} + + public func install( + serverURL: String, + enrollmentToken: String, + configDir: String = ProboAgentHelperConstants.defaultConfigDir + ) throws { + operationLock.lock() + defer { operationLock.unlock() } + + try ensureHelperReady() + try withRemoteProxy { proxy, completion in + proxy.install( + serverURL: serverURL, + enrollmentToken: enrollmentToken, + configDir: configDir + ) { exitCode, output in + if exitCode != 0 { + completion.fail(HelperClientError.operationFailed(exitCode, output)) + } else { + completion.succeed() + } + } + + // Headroom over probo-agent install's 60s deadline plus local + // service/tray setup so we report the command's real outcome. + try completion.wait( + timeout: 120, + timedOut: HelperClientError.connectionFailed( + "install timed out waiting for privileged helper" + )) + } + } + + /// The helper is installed by the PKG postinstall (as root). Enrollment + /// never calls SMJobBless — no admin prompt on the browser path. + private func ensureHelperReady() throws { + guard isHelperInstalled() else { + throw HelperClientError.helperNotInstalled + } + + let installedVersion = try installedHelperVersion() + if installedVersion == nil { + throw HelperClientError.connectionFailed( + "helper is installed but not responding; reinstall the Probo Agent package" + ) + } + + if installedVersion != ProboAgentHelperConstants.helperVersion { + NSLog( + "probo-agent helper client: version mismatch (installed=%@ expected=%@)", + installedVersion ?? "nil", + ProboAgentHelperConstants.helperVersion + ) + } + + try verifyHelperResponds() + } + + private func isHelperInstalled() -> Bool { + FileManager.default.fileExists( + atPath: "/Library/PrivilegedHelperTools/\(ProboAgentHelperConstants.helperLabel)" + ) + } + + private func installedHelperVersion() throws -> String? { + try withRemoteProxy { proxy, completion in + var version: String? + + proxy.getVersion { value in + version = value + completion.succeed() + } + + guard try completion.wait(timeout: 10) else { + return nil + } + + return version + } + } + + private func verifyHelperResponds() throws { + try withRemoteProxy { proxy, completion in + var ok = false + + proxy.ping { value in + ok = value + completion.succeed() + } + + try completion.wait( + timeout: 10, + timedOut: HelperClientError.connectionFailed( + "helper did not respond to ping (timeout)") + ) + if !ok { + throw HelperClientError.connectionFailed("helper did not respond to ping") + } + } + } + + /// Creates a dedicated XPC connection for the duration of `body`, then + /// invalidates it. Callers must finish waiting for replies inside `body` + /// so the connection outlives the reply. + private func withRemoteProxy( + _ body: (ProboAgentHelperProtocol, XPCCallCompletion) throws -> T + ) throws -> T { + let connection = NSXPCConnection( + machServiceName: ProboAgentHelperConstants.machServiceName, + options: .privileged + ) + connection.remoteObjectInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self) + connection.resume() + defer { connection.invalidate() } + + let completion = XPCCallCompletion() + guard + let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + NSLog( + "probo-agent helper XPC error: %@", + error.localizedDescription + ) + completion.fail( + HelperClientError.connectionFailed(error.localizedDescription) + ) + }) as? ProboAgentHelperProtocol + else { + throw HelperClientError.connectionFailed("cannot create remote proxy") + } + + return try body(proxy, completion) + } +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Helper.swift b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Helper.swift new file mode 100644 index 000000000..706c8dd61 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Helper.swift @@ -0,0 +1,103 @@ +import Foundation +import ProboAgentShared +import os + +final class Helper: NSObject, ProboAgentHelperProtocol, NSXPCListenerDelegate { + private static let log = Logger( + subsystem: "com.probo.agent.helper", + category: "Helper" + ) + + func getVersion(withReply reply: @escaping (String) -> Void) { + reply(ProboAgentHelperConstants.helperVersion) + } + + func ping(withReply reply: @escaping (Bool) -> Void) { + Self.log.info("ping") + reply(true) + } + + func install( + serverURL: String, + enrollmentToken: String, + configDir: String, + withReply reply: @escaping (Int32, String?) -> Void + ) { + let trimmedServer = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = enrollmentToken.trimmingCharacters(in: .whitespacesAndNewlines) + let dir = configDir.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedServer.isEmpty, !trimmedToken.isEmpty else { + reply(1, "server URL and enrollment token are required") + return + } + + // Pass the token via env rather than argv so it does not show up in + // process listings (ps / Activity Monitor). Install already accepts + // PROBO_ENROLLMENT_TOKEN when --enrollment-token is omitted. + var args = [ + "install", + "--server", trimmedServer, + ] + if !dir.isEmpty { + args.append(contentsOf: ["--dir", dir]) + } + + var environment = ProcessInfo.processInfo.environment + environment["PROBO_ENROLLMENT_TOKEN"] = trimmedToken + + let result = runAgent(args: args, environment: environment) + reply(result.exitCode, result.output) + } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) + -> Bool + { + guard ClientAuth.accepts(connection: connection) else { + Self.log.error( + "refused XPC connection pid=\(connection.processIdentifier, privacy: .public)") + return false + } + + Self.log.info( + "accepted XPC connection pid=\(connection.processIdentifier, privacy: .public)") + connection.exportedInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self) + connection.exportedObject = self + connection.resume() + return true + } + + private struct CommandResult { + let exitCode: Int32 + let output: String? + } + + private func runAgent(args: [String], environment: [String: String]? = nil) -> CommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: ProboAgentHelperConstants.agentExecutablePath) + process.arguments = args + if let environment { + process.environment = environment + } + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + let data = try pipe.fileHandleForReading.readToEnd() ?? Data() + process.waitUntilExit() + let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + return CommandResult( + exitCode: process.terminationStatus, + output: text?.isEmpty == false ? text : nil + ) + } catch { + return CommandResult(exitCode: 1, output: error.localizedDescription) + } + } + +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Info.plist.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Info.plist.tmpl new file mode 100644 index 000000000..152d38402 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Info.plist.tmpl @@ -0,0 +1,21 @@ + + + + + CFBundleIdentifier + com.probo.agent.helper + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Probo Agent Helper + CFBundleShortVersionString + @@VERSION@@ + CFBundleVersion + @@VERSION@@ + SMAuthorizedClients + + @@CLIENT_DESIGNATED_REQUIREMENT@@ + + + diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Launchd.plist.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Launchd.plist.tmpl new file mode 100644 index 000000000..2a57e58d9 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/Launchd.plist.tmpl @@ -0,0 +1,18 @@ + + + + + Label + com.probo.agent.helper + MachServices + + com.probo.agent.helper + + + AssociatedBundleIdentifiers + + com.probo.agent.url-handler + + + diff --git a/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/main.swift b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/main.swift new file mode 100644 index 000000000..6da2f6c43 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/HelperTool/main.swift @@ -0,0 +1,13 @@ +import Foundation +import ProboAgentShared +import os + +private let log = Logger(subsystem: "com.probo.agent.helper", category: "main") + +let helper = Helper() +let listener = NSXPCListener(machServiceName: ProboAgentHelperConstants.machServiceName) +listener.delegate = helper +listener.resume() +log.info("listening on \(ProboAgentHelperConstants.machServiceName, privacy: .public)") + +RunLoop.main.run() diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl index 1bc39ab5e..06d06384c 100644 --- a/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl +++ b/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl @@ -6,7 +6,8 @@ Placeholders are substituted by build-app.sh: - @@VERSION@@ agent version, e.g. 0.1.0 + @@VERSION@@ agent version, e.g. 0.1.0 + @@HELPER_DESIGNATED_REQUIREMENT@@ codesign requirement for embedded helper --> @@ -15,7 +16,7 @@ CFBundleExecutable probo-agent-url-handler CFBundleIdentifier - com.getprobo.agent.url-handler + com.probo.agent.url-handler CFBundleName Probo Agent CFBundleDisplayName @@ -41,5 +42,10 @@ + SMPrivilegedExecutables + + com.probo.agent.helper + @@HELPER_DESIGNATED_REQUIREMENT@@ + diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Package.swift b/cmd/probo-agent/installer/macos/enroll-ui/Package.swift index 29bb9e7ad..89bfb36e4 100644 --- a/cmd/probo-agent/installer/macos/enroll-ui/Package.swift +++ b/cmd/probo-agent/installer/macos/enroll-ui/Package.swift @@ -4,11 +4,43 @@ import PackageDescription let package = Package( name: "probo-agent-url-handler", platforms: [ - .macOS(.v11), + .macOS(.v11) + ], + products: [ + .library(name: "ProboAgentShared", targets: ["ProboAgentShared"]), + .library(name: "HelperClient", targets: ["HelperClient"]), + .executable(name: "com.probo.agent.helper", targets: ["com.probo.agent.helper"]), + .executable(name: "probo-agent-url-handler", targets: ["probo-agent-url-handler"]), ], targets: [ + .target( + name: "ProboAgentShared", + path: "Shared", + exclude: [ + "HelperVersion.generated.swift.tmpl", + "SigningConstants.generated.swift.tmpl", + ], + linkerSettings: [ + .linkedFramework("Security") + ] + ), + .target( + name: "HelperClient", + dependencies: ["ProboAgentShared"], + path: "HelperClient" + ), + .executableTarget( + name: "com.probo.agent.helper", + dependencies: ["ProboAgentShared"], + path: "HelperTool", + exclude: ["Info.plist.tmpl", "Launchd.plist.tmpl"], + linkerSettings: [ + .linkedFramework("Security") + ] + ), .executableTarget( name: "probo-agent-url-handler", + dependencies: ["HelperClient", "ProboAgentShared"], path: "URLHandlerSources" ), ] diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Shared/ClientAuth.swift b/cmd/probo-agent/installer/macos/enroll-ui/Shared/ClientAuth.swift new file mode 100644 index 000000000..23cffd4c8 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Shared/ClientAuth.swift @@ -0,0 +1,183 @@ +import Foundation +import Security +import os + +public enum ClientAuth { + private static let log = Logger( + subsystem: "com.probo.agent.helper", + category: "ClientAuth" + ) + + /// Validates an incoming XPC connection from the Probo Agent URL handler app. + public static func accepts(connection: NSXPCConnection) -> Bool { + guard let code = copyGuestCode(for: connection) else { + return false + } + + // Developer ID + hardened runtime sets CS_RUNTIME on the code directory + // (kSecCodeInfoFlags). kSecCodeInfoStatus is only present for some + // dynamic queries and was missing here, which rejected every client. + if !hasAcceptableCodeStatus(code) { + return false + } + + guard let requirement = clientRequirement() else { + log.error("reject XPC client (missing client requirement)") + return false + } + + var secRequirement: SecRequirement? + guard + SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &secRequirement + ) == errSecSuccess, let secRequirement + else { + log.error("reject XPC client (invalid requirement string)") + return false + } + + let check = SecCodeCheckValidity(code, SecCSFlags(), secRequirement) + if check != errSecSuccess { + log.error("reject XPC client (requirement not satisfied, status=\(check))") + return false + } + + log.info("accepted XPC client pid=\(connection.processIdentifier, privacy: .public)") + return true + } + + private static func copyGuestCode(for connection: NSXPCConnection) -> SecCode? { + if let token = auditToken(from: connection) { + var mutableToken = token + let tokenData = withUnsafeBytes(of: &mutableToken) { Data($0) } + if let code = copyGuestCode(attributes: [ + kSecGuestAttributeAudit as String: tokenData + ]) { + return code + } + log.info("audit-token guest lookup failed; trying pid") + } else { + log.info("no audit token on XPC connection; trying pid") + } + + let pid = connection.processIdentifier + guard pid > 0 else { + log.error("reject XPC client (invalid pid)") + return nil + } + + return copyGuestCode(attributes: [ + kSecGuestAttributePid as String: NSNumber(value: pid) + ]) + } + + private static func copyGuestCode(attributes: [String: Any]) -> SecCode? { + var code: SecCode? + let status = SecCodeCopyGuestWithAttributes( + nil, + attributes as CFDictionary, + SecCSFlags(), + &code + ) + guard status == errSecSuccess else { + log.error("reject XPC client (SecCodeCopyGuestWithAttributes=\(status))") + return nil + } + return code + } + + private static func hasAcceptableCodeStatus(_ code: SecCode) -> Bool { + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { + log.error("reject XPC client (cannot copy static code)") + return false + } + + var csInfo: CFDictionary? + guard + SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSDynamicInformation), + &csInfo + ) == errSecSuccess, + let info = csInfo as? [String: Any] + else { + log.error("reject XPC client (cannot read signing information)") + return false + } + + // Prefer dynamic status when present; fall back to code-directory flags + // (where hardened-runtime CS_RUNTIME lives for Developer ID binaries). + let statusValue = uint32Value(info[kSecCodeInfoStatus as String]) + let flagsValue = uint32Value(info[kSecCodeInfoFlags as String]) + let bits = statusValue ?? flagsValue + + guard let bits else { + log.error("reject XPC client (no status/flags in signing info)") + return false + } + + // Accept hardened-runtime clients (Developer ID + --options runtime sets + // CS_RUNTIME). Also accept the older CS_HARD|CS_KILL pair. + let csHard: UInt32 = 0x100 + let csKill: UInt32 = 0x200 + let csRuntime: UInt32 = 0x10000 + let hasHardKill = (bits & (csHard | csKill)) == (csHard | csKill) + let hasRuntime = (bits & csRuntime) == csRuntime + if !hasHardKill && !hasRuntime { + log.error( + "reject XPC client (bits=0x\(String(bits, radix: 16)), need runtime or hard|kill)" + ) + return false + } + + return true + } + + private static func uint32Value(_ value: Any?) -> UInt32? { + switch value { + case let number as NSNumber: + return number.uint32Value + case let value as UInt32: + return value + case let value as Int: + return UInt32(truncatingIfNeeded: value) + default: + return nil + } + } + + /// Reads NSXPCConnection.auditToken across SDK/runtime differences. + /// Modern macOS exposes it as an ObjC property; KVC may return Data or NSValue. + private static func auditToken(from connection: NSXPCConnection) -> audit_token_t? { + if let data = connection.value(forKey: "auditToken") as? Data, + data.count == MemoryLayout.size + { + return data.withUnsafeBytes { raw in + raw.load(as: audit_token_t.self) + } + } + + if let value = connection.value(forKey: "auditToken") as? NSValue { + var token = audit_token_t() + value.getValue(&token) + return token + } + + return nil + } + + private static func clientRequirement() -> String? { + guard let teamID = ProboAgentSigningConstants.teamID, !teamID.isEmpty else { + return nil + } + return """ + anchor apple generic and identifier "\(ProboAgentHelperConstants.clientBundleID)" \ + and certificate leaf[subject.OU] = "\(teamID)" + """ + } +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperProtocol.swift b/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperProtocol.swift new file mode 100644 index 000000000..dc6277b3e --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperProtocol.swift @@ -0,0 +1,13 @@ +import Foundation + +@objc(ProboAgentHelperProtocol) +public protocol ProboAgentHelperProtocol: NSObjectProtocol { + func getVersion(withReply reply: @escaping (String) -> Void) + func ping(withReply reply: @escaping (Bool) -> Void) + func install( + serverURL: String, + enrollmentToken: String, + configDir: String, + withReply reply: @escaping (Int32, String?) -> Void + ) +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperVersion.generated.swift.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperVersion.generated.swift.tmpl new file mode 100644 index 000000000..7cf2e83d6 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Shared/HelperVersion.generated.swift.tmpl @@ -0,0 +1,4 @@ +// Generated by build-app.sh — do not edit. +enum ProboAgentHelperVersion { + static let value = "@@VERSION@@" +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Shared/ProboAgentHelperConstants.swift b/cmd/probo-agent/installer/macos/enroll-ui/Shared/ProboAgentHelperConstants.swift new file mode 100644 index 000000000..66c792cb8 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Shared/ProboAgentHelperConstants.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum ProboAgentHelperConstants { + public static let machServiceName = "com.probo.agent.helper" + public static let helperLabel = "com.probo.agent.helper" + public static let clientBundleID = "com.probo.agent.url-handler" + public static let agentExecutablePath = "/usr/local/bin/probo-agent" + public static let defaultConfigDir = "/var/lib/probo-agent" + public static let enrolledMarkerPath = "/var/run/probo-agent/enrolled" + + public static let helperVersion = ProboAgentHelperVersion.value +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Shared/SigningConstants.generated.swift.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/Shared/SigningConstants.generated.swift.tmpl new file mode 100644 index 000000000..8ee8e8e95 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Shared/SigningConstants.generated.swift.tmpl @@ -0,0 +1,4 @@ +// Generated by build-app.sh — do not edit. +enum ProboAgentSigningConstants { + static let teamID: String? = @@TEAM_ID_OPTION@@ +} diff --git a/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift b/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift index 08feffbe3..4e1f7b97b 100644 --- a/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift +++ b/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift @@ -1,61 +1,10 @@ import AppKit -import Darwin import Foundation - -// Fixed install location written by the macOS PKG postinstall script -// (cmd/probo-agent/installer/macos/scripts/postinstall, BINARY). -private let agentExecutablePath = "/usr/local/bin/probo-agent" - -private enum EnrollmentCallbackState: String, Codable { - case success - case failure -} - -private struct EnrollmentCallbackPayload: Codable { - let state: EnrollmentCallbackState - let message: String? -} - -private enum EnrollmentCallbackStore { - static var statusFileURL: URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("probo-agent-enrollment-status.json") - } - - static var lockFileURL: URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("probo-agent-enrollment-ui.lock") - } - - static func isWizardRunning() -> Bool { - guard - let data = try? Data(contentsOf: lockFileURL), - let pidText = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - let pid = Int32(pidText), - pid > 0 - else { - return false - } - - return kill(pid, 0) == 0 - } - - static func writeStatus( - state: EnrollmentCallbackState, - message: String? - ) { - let payload = EnrollmentCallbackPayload(state: state, message: message) - guard let data = try? JSONEncoder().encode(payload) else { - return - } - - try? data.write(to: statusFileURL, options: [.atomic]) - } -} +import HelperClient private final class URLHandlerApp: NSObject, NSApplicationDelegate { private var didReceiveURL = false + private var idleTimer: Timer? override init() { super.init() @@ -69,127 +18,89 @@ private final class URLHandlerApp: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in - if !self.didReceiveURL { - NSApp.terminate(nil) - } + NSLog("probo-agent url-handler: launched") + idleTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in + guard let self, !self.didReceiveURL else { return } + NSLog("probo-agent url-handler: no URL received; exiting") + NSApp.terminate(nil) } } + func application(_ application: NSApplication, open urls: [URL]) { + guard let rawURL = urls.first?.absoluteString else { return } + beginEnrollmentIfNeeded(rawURL: rawURL) + } + @objc private func handleGetURLEvent( _ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor ) { guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { - reportFailure("Enrollment link is missing.") + presentFailure("Enrollment link is missing.") return } - guard !didReceiveURL else { return } + beginEnrollmentIfNeeded(rawURL: rawURL) + } + private func beginEnrollmentIfNeeded(rawURL: String) { + guard !didReceiveURL else { return } didReceiveURL = true + idleTimer?.invalidate() + idleTimer = nil runEnrollment(for: rawURL) } private func runEnrollment(for rawURL: String) { - let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning() + NSLog("probo-agent url-handler: starting enrollment") DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = URL(fileURLWithPath: agentExecutablePath) - process.arguments = ["enroll-url", rawURL] - - let output = Pipe() - process.standardOutput = output - process.standardError = output - - var outputData = Data() - let readHandle = output.fileHandleForReading - let readDone = DispatchSemaphore(value: 0) - - readHandle.readabilityHandler = { handle in - let chunk = handle.availableData - if chunk.isEmpty { - handle.readabilityHandler = nil - readDone.signal() + do { + NSLog("probo-agent url-handler: preflight…") + let preflight = try EnrollmentFlow.runPreflight(rawURL: rawURL) + if preflight.alreadyEnrolled { + NSLog("probo-agent url-handler: already enrolled") + DispatchQueue.main.async { + NSApp.terminate(nil) + } return } - outputData.append(chunk) - } - defer { readHandle.readabilityHandler = nil } + NSLog("probo-agent url-handler: install via helper…") + try EnrollmentFlow.installViaHelper(preflight: preflight) + NSLog("probo-agent url-handler: install completed") - do { - try process.run() + DispatchQueue.main.async { + NSApp.terminate(nil) + } } catch { - readDone.signal() + NSLog( + "probo-agent url-handler: enrollment failed: %@", + error.localizedDescription + ) DispatchQueue.main.async { - self.reportFailure( - self.sanitizedFailureMessage(error.localizedDescription), - shouldNotifyWizard: shouldNotifyWizard - ) + self.presentFailure(error.localizedDescription) } - return - } - - process.waitUntilExit() - readDone.wait() - - guard process.terminationStatus == 0 else { - let message = String(data: outputData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - - DispatchQueue.main.async { - self.reportFailure( - self.sanitizedFailureMessage(message), - shouldNotifyWizard: shouldNotifyWizard - ) - } - return - } - - DispatchQueue.main.async { - if shouldNotifyWizard { - EnrollmentCallbackStore.writeStatus(state: .success, message: nil) - } - NSApp.terminate(nil) } } } - private func reportFailure(_ message: String, shouldNotifyWizard: Bool = EnrollmentCallbackStore.isWizardRunning()) { - if shouldNotifyWizard { - EnrollmentCallbackStore.writeStatus(state: .failure, message: message) - NSApp.terminate(nil) - return - } - - showError(message) + private func presentFailure(_ message: String) { + presentAlert(title: "Enrollment failed", message: message, style: .warning) } - private func sanitizedFailureMessage(_ raw: String?) -> String { - guard let raw else { - return "Enrollment failed. Please try again." - } - - let normalized = raw.lowercased() - if normalized.contains("already enrolled") { - return "This device is already enrolled." - } - if normalized.contains("key") && normalized.contains("missing") { - return "Device API key is missing or invalid." - } - - return "Enrollment failed. Please try again." - } - - private func showError(_ message: String) { - let alert = NSAlert() - alert.messageText = "Enrollment failed" - alert.informativeText = message - alert.alertStyle = .warning + private func presentAlert(title: String, message: String, style: NSAlert.Style) { + // LSUIElement / .accessory apps otherwise show alerts that never appear. + NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = style alert.runModal() + + NSApp.setActivationPolicy(.accessory) NSApp.terminate(nil) } } 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 54364d7f6..ba7f96310 100755 --- a/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh +++ b/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh @@ -1,14 +1,18 @@ #!/bin/bash # -# Build Probo Agent.app — a headless macOS app bundle that registers -# the probo:// URL scheme and forwards enrollment links to probo-agent. +# Build Probo Agent.app — headless macOS app bundle with probo:// handler +# and embedded privileged helper (installed by PKG postinstall). # # Required arguments: # --arch amd64, arm64, or universal # --version Agent version, e.g. 0.1.0 # --output Parent directory; creates "Probo Agent.app" inside it # -# Must run on macOS with the Swift toolchain (swift build). +# Required environment variables: +# CODESIGN_IDENTITY Developer ID Application identity. Signs the +# embedded helper (for SMPrivilegedExecutables DR), +# URL handler, and app bundle. +# APPLE_TEAM_ID Apple Developer Team ID for SMAuthorizedClients. set -euo pipefail @@ -18,7 +22,10 @@ ARCH="" VERSION="" OUTPUT="" APP_NAME="Probo Agent.app" -EXECUTABLE_NAME="probo-agent-url-handler" +URL_HANDLER_NAME="probo-agent-url-handler" +HELPER_LABEL="com.probo.agent.helper" +CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}" +APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" usage() { sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" @@ -60,33 +67,123 @@ if ! command -v swift >/dev/null 2>&1; then echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2 exit 1 fi +if [ -z "${CODESIGN_IDENTITY}" ]; then + echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2 + exit 2 +fi +if [ -z "${APPLE_TEAM_ID}" ]; then + echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2 + exit 2 +fi -BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)" +BUILD_DIR="$(mktemp -d -t probo-agent-enroll-ui-build)" +RENDER_DIR="${BUILD_DIR}/rendered" trap 'rm -rf "${BUILD_DIR}"' EXIT +mkdir -p "${RENDER_DIR}" + +client_requirement() { + if [ -z "${APPLE_TEAM_ID}" ]; then + echo "error: APPLE_TEAM_ID is required (client designated requirement)" >&2 + exit 2 + fi + printf 'anchor apple generic and identifier "com.probo.agent.url-handler" and certificate leaf[subject.OU] = "%s"' "${APPLE_TEAM_ID}" +} + +team_id_option() { + if [ -n "${APPLE_TEAM_ID}" ]; then + printf '"%s"' "${APPLE_TEAM_ID}" + return + fi + printf 'nil' +} + +sed \ + -e "s|@@VERSION@@|${VERSION}|g" \ + "${SCRIPT_DIR}/Shared/HelperVersion.generated.swift.tmpl" \ + > "${SCRIPT_DIR}/Shared/HelperVersion.generated.swift" + +sed \ + -e "s|@@TEAM_ID_OPTION@@|$(team_id_option)|g" \ + "${SCRIPT_DIR}/Shared/SigningConstants.generated.swift.tmpl" \ + > "${SCRIPT_DIR}/Shared/SigningConstants.generated.swift" + +HELPER_INFO_PLIST="${RENDER_DIR}/helper-info.plist" +HELPER_LAUNCHD_PLIST="${RENDER_DIR}/helper-launchd.plist" + +sed \ + -e "s|@@VERSION@@|${VERSION}|g" \ + -e "s|@@CLIENT_DESIGNATED_REQUIREMENT@@|$(client_requirement)|g" \ + "${SCRIPT_DIR}/HelperTool/Info.plist.tmpl" > "${HELPER_INFO_PLIST}" + +cp "${SCRIPT_DIR}/HelperTool/Launchd.plist.tmpl" "${HELPER_LAUNCHD_PLIST}" + +HELPER_LINKER_FLAGS=( + -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist + -Xlinker "${HELPER_INFO_PLIST}" + -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __launchd_plist + -Xlinker "${HELPER_LAUNCHD_PLIST}" +) pushd "${SCRIPT_DIR}" >/dev/null -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}" +swift build -c release "${SWIFT_ARCH_ARGS[@]}" \ + --scratch-path "${BUILD_DIR}/swift" \ + --product "${HELPER_LABEL}" \ + "${HELPER_LINKER_FLAGS[@]}" + +swift build -c release "${SWIFT_ARCH_ARGS[@]}" \ + --scratch-path "${BUILD_DIR}/swift" \ + --product "${URL_HANDLER_NAME}" + +BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" \ + --scratch-path "${BUILD_DIR}/swift" --show-bin-path)" +HELPER_BINARY="${BIN_DIR}/${HELPER_LABEL}" +URL_HANDLER_BINARY="${BIN_DIR}/${URL_HANDLER_NAME}" popd >/dev/null -if [ ! -x "${BINARY}" ]; then - echo "error: release binary not found at ${BINARY}" >&2 +if [ ! -x "${HELPER_BINARY}" ] || [ ! -x "${URL_HANDLER_BINARY}" ]; then + echo "error: expected release binaries were not produced" >&2 exit 1 fi APP_ROOT="${OUTPUT}/${APP_NAME}" CONTENTS="${APP_ROOT}/Contents" MACOS="${CONTENTS}/MacOS" +LAUNCH_SERVICES="${CONTENTS}/Library/LaunchServices" +LAUNCH_DAEMONS="${CONTENTS}/Library/LaunchDaemons" PLIST="${CONTENTS}/Info.plist" +EMBEDDED_HELPER="${LAUNCH_SERVICES}/${HELPER_LABEL}" +EMBEDDED_LAUNCHD="${LAUNCH_DAEMONS}/${HELPER_LABEL}.plist" rm -rf "${APP_ROOT}" -mkdir -p "${MACOS}" +mkdir -p "${MACOS}" "${LAUNCH_SERVICES}" "${LAUNCH_DAEMONS}" -install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}" +install -m 0755 "${URL_HANDLER_BINARY}" "${MACOS}/${URL_HANDLER_NAME}" +install -m 0755 "${HELPER_BINARY}" "${EMBEDDED_HELPER}" +install -m 0644 "${HELPER_LAUNCHD_PLIST}" "${EMBEDDED_LAUNCHD}" + +codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "${CODESIGN_IDENTITY}" \ + "${EMBEDDED_HELPER}" +codesign --verify --verbose=2 "${EMBEDDED_HELPER}" +# codesign prints "Executable=…" on stderr and either +# "# designated => …" (modern) or "designated => …" (older) on stdout. +HELPER_REQUIREMENT="$( + codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 \ + | sed -n -e 's/^# designated => //p' -e 's/^designated => //p' +)" +if [ -z "${HELPER_REQUIREMENT}" ]; then + echo "error: cannot extract designated requirement from signed helper" >&2 + codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 >&2 || true + exit 1 +fi +echo "Helper designated requirement: ${HELPER_REQUIREMENT}" sed \ -e "s|@@VERSION@@|${VERSION}|g" \ + -e "s|@@HELPER_DESIGNATED_REQUIREMENT@@|${HELPER_REQUIREMENT}|g" \ "${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}" if ! plutil -lint "${PLIST}" >/dev/null; then @@ -98,4 +195,18 @@ if ! grep -q 'probo' "${PLIST}"; then exit 1 fi +codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "${CODESIGN_IDENTITY}" \ + "${MACOS}/${URL_HANDLER_NAME}" +codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "${CODESIGN_IDENTITY}" \ + "${APP_ROOT}" +codesign --verify --verbose=2 "${APP_ROOT}" + echo "Built ${APP_ROOT}" diff --git a/cmd/probo-agent/installer/macos/reinstall.sh b/cmd/probo-agent/installer/macos/reinstall.sh new file mode 100755 index 000000000..8b9733057 --- /dev/null +++ b/cmd/probo-agent/installer/macos/reinstall.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Tear down any previous macOS install, then install a signed .pkg. +# Must run as root. Used by: make -C cmd/probo-agent install +# +# Usage: reinstall.sh /path/to/probo-agent_*.pkg + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG="${1:-}" + +if [ "$(id -u)" -ne 0 ]; then + echo "error: must run as root (try: sudo make -C cmd/probo-agent install)" >&2 + exit 1 +fi + +if [ -z "${PKG}" ] || [ ! -f "${PKG}" ]; then + echo "error: usage: $0 /path/to/probo-agent_*.pkg" >&2 + exit 2 +fi + +"${SCRIPT_DIR}/uninstall.sh" +echo "Installing ${PKG}…" +installer -pkg "${PKG}" -target / +echo "PKG install complete." diff --git a/cmd/probo-agent/installer/macos/scripts/postinstall b/cmd/probo-agent/installer/macos/scripts/postinstall index 459324a12..5416ab0d0 100755 --- a/cmd/probo-agent/installer/macos/scripts/postinstall +++ b/cmd/probo-agent/installer/macos/scripts/postinstall @@ -22,6 +22,10 @@ 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" +HELPER_LABEL="com.probo.agent.helper" +HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist" +HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}" +APP_PATH="/Applications/Probo Agent.app" TRAY_LABEL="com.probo.agent.tray" TRAY_PLIST_NAME="${TRAY_LABEL}.plist" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -133,11 +137,10 @@ register_tray_launchagent() { } register_enrollment_url_scheme() { - local app_path lsregister + local lsregister - app_path="/Applications/Probo Agent.app" - if [ ! -d "${app_path}" ]; then - echo "warning: ${app_path} not found; cannot register probo:// URL scheme." + if [ ! -d "${APP_PATH}" ]; then + echo "warning: ${APP_PATH} not found; cannot register probo:// URL scheme." return 0 fi @@ -147,7 +150,7 @@ register_enrollment_url_scheme() { return 0 fi - if ! "${lsregister}" -f "${app_path}"; then + if ! "${lsregister}" -f "${APP_PATH}"; then echo "warning: failed to register probo:// URL scheme." return 0 fi @@ -155,6 +158,62 @@ register_enrollment_url_scheme() { echo "Registered probo:// URL scheme." } +# Install the privileged helper as root during PKG install so browser +# enrollment can use XPC without SMJobBless / an admin password prompt. +install_privileged_helper() { + local src_helper="${APP_PATH}/Contents/Library/LaunchServices/${HELPER_LABEL}" + + if [ ! -x "${src_helper}" ]; then + echo "error: privileged helper missing at ${src_helper}" + return 1 + fi + + mkdir -p /Library/PrivilegedHelperTools /Library/LaunchDaemons + + if [ -f "${HELPER_PLIST}" ]; then + launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true + fi + + # Match SMJobBless-style permissions (root:wheel, not world-writable). + install -m 0544 -o root -g wheel "${src_helper}" "${HELPER_BINARY}" + + cat > "${HELPER_PLIST}" < + + + + Label + ${HELPER_LABEL} + Program + ${HELPER_BINARY} + ProgramArguments + + ${HELPER_BINARY} + + MachServices + + ${HELPER_LABEL} + + + AssociatedBundleIdentifiers + + com.probo.agent.url-handler + + + +EOF + chmod 0644 "${HELPER_PLIST}" + chown root:wheel "${HELPER_PLIST}" + + if ! launchctl bootstrap system "${HELPER_PLIST}"; then + echo "warning: could not bootstrap ${HELPER_LABEL}; first XPC connect may start it." + return 0 + fi + + echo "Installed privileged helper at ${HELPER_BINARY}." + return 0 +} + # 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. @@ -246,6 +305,11 @@ else echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon." fi +if ! install_privileged_helper; then + echo "error: privileged helper installation failed; browser enrollment will not work." + exit 1 +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 index ad9f70190..5dd628f0c 100755 --- a/cmd/probo-agent/installer/macos/scripts/preinstall +++ b/cmd/probo-agent/installer/macos/scripts/preinstall @@ -12,6 +12,9 @@ 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" +HELPER_LABEL="com.probo.agent.helper" +HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist" +HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}" mkdir -p "$(dirname "${LOG_FILE}")" exec > >(tee -a "${LOG_FILE}") 2>&1 @@ -54,6 +57,17 @@ if [ -f "${DAEMON_PLIST}" ]; then echo "Booted out LaunchDaemon at ${DAEMON_PLIST}." fi +if [ -f "${HELPER_PLIST}" ]; then + launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true + rm -f "${HELPER_PLIST}" + echo "Removed privileged helper LaunchDaemon at ${HELPER_PLIST}." +fi + +if [ -f "${HELPER_BINARY}" ]; then + rm -f "${HELPER_BINARY}" + echo "Removed privileged helper binary at ${HELPER_BINARY}." +fi + if [ -f "${TRAY_PLIST}" ]; then echo "Existing tray LaunchAgent will be replaced by postinstall." fi diff --git a/cmd/probo-agent/installer/macos/uninstall.sh b/cmd/probo-agent/installer/macos/uninstall.sh new file mode 100755 index 000000000..ea578e2e5 --- /dev/null +++ b/cmd/probo-agent/installer/macos/uninstall.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# +# Fully remove a macOS PKG install of probo-agent. +# +# Idempotent: missing pieces are skipped. Must run as root. +# +# Removes: +# - agent service, tray LaunchAgent, privileged helper +# - binary, Probo Agent.app (and .localized variants) +# - state/run dirs, logs +# - PKG receipt (com.probo.agent) +# - stale Launch Services registration for the URL handler + +set -u + +BINARY="/usr/local/bin/probo-agent" +STATE_DIR="/var/lib/probo-agent" +RUN_DIR="/var/run/probo-agent" +DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist" +HELPER_LABEL="com.probo.agent.helper" +HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist" +HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}" +TRAY_LABEL="com.probo.agent.tray" +TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist" +PKG_ID="com.probo.agent" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + +log() { + printf '%s\n' "$*" +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + die "must run as root (try: sudo make -C cmd/probo-agent uninstall)" + fi +} + +bootout_system_plist() { + local plist="$1" + if [ -f "${plist}" ]; then + launchctl bootout system "${plist}" 2>/dev/null || true + log "Booted out ${plist}" + fi +} + +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 +} + +unregister_apps() { + local path + for path in \ + "/Applications/Probo Agent.app" \ + "/Applications/Probo Agent.localized/Probo Agent.app" + do + if [ -d "${path}" ] && [ -x "${LSREGISTER}" ]; then + "${LSREGISTER}" -u "${path}" 2>/dev/null || true + log "Unregistered Launch Services entry for ${path}" + fi + done +} + +kill_leftovers() { + # Best-effort; deleted-but-running binaries otherwise keep claiming probo://. + pkill -x probo-agent-url-handler 2>/dev/null || true + pkill -f '/usr/local/bin/probo-agent tray' 2>/dev/null || true + pkill -f '/Library/PrivilegedHelperTools/com.probo.agent.helper' 2>/dev/null || true + # Agent daemon may still be running after plist bootout races. + pkill -x probo-agent 2>/dev/null || true +} + +require_root + +if [ "$(uname -s)" != "Darwin" ]; then + die "this uninstall script is macOS-only" +fi + +log "=== probo-agent macOS uninstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" + +# Prefer the agent's own uninstall for service/tray/state when present. +if [ -x "${BINARY}" ]; then + if "${BINARY}" uninstall; then + log "Ran: ${BINARY} uninstall" + else + log "warning: ${BINARY} uninstall failed; continuing with manual cleanup" + fi +else + log "Binary not found at ${BINARY}; skipping probo-agent uninstall" +fi + +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 +bootout_tray_for_user "$(stat -f "%Su" /dev/console 2>/dev/null || true)" + +bootout_system_plist "${DAEMON_PLIST}" +bootout_system_plist "${HELPER_PLIST}" + +kill_leftovers + +rm -f "${DAEMON_PLIST}" "${HELPER_PLIST}" "${HELPER_BINARY}" "${TRAY_PLIST}" +log "Removed LaunchDaemon / LaunchAgent / helper files (if present)" + +unregister_apps +rm -rf \ + "/Applications/Probo Agent.app" \ + "/Applications/Probo Agent.localized" +log "Removed Probo Agent.app (if present)" + +rm -f "${BINARY}" +rm -rf "${STATE_DIR}" "${RUN_DIR}" +rm -f \ + /var/log/probo-agent.log \ + /var/log/probo-agent-install.log \ + /tmp/probo-agent.conf +log "Removed binary, state, run dir, logs, and staged conf (if present)" + +if pkgutil --pkg-info "${PKG_ID}" >/dev/null 2>&1; then + if ! pkgutil --forget "${PKG_ID}" >/dev/null; then + die "failed to forget PKG receipt ${PKG_ID}" + fi + log "Forgot PKG receipt ${PKG_ID}" +fi + +log "=== uninstall done ===" diff --git a/cmd/probo-agent/main.go b/cmd/probo-agent/main.go index 9a75be5b9..93af350fc 100644 --- a/cmd/probo-agent/main.go +++ b/cmd/probo-agent/main.go @@ -25,9 +25,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/signal" "path/filepath" + "runtime" "syscall" "time" @@ -100,6 +102,8 @@ func newRootCmd() *cobra.Command { } func newEnrollURLCmd() *cobra.Command { + var preflight bool + cmd := &cobra.Command{ Use: "enroll-url [url]", Hidden: true, @@ -112,14 +116,32 @@ func newEnrollURLCmd() *cobra.Command { dir := resolveDir(cmd) + if preflight { + enrolled, err := deviceagent.IsEnrolled(deviceagent.EnrollmentRunDir(dir)) + if err != nil { + return fmt.Errorf("cannot check enrollment state: %w", err) + } + + return writeEnrollPreflight(cmd.OutOrStdout(), serverURL, enrollmentToken, dir, enrolled) + } + already, err := reportIfAlreadyEnrolled(dir) if err != nil { return err } + if already { return nil } + if runtime.GOOS == "darwin" { + return fmt.Errorf( + "macOS browser enrollment must use the signed Probo Agent.app " + + "(probo:// deeplink); for CLI use: sudo probo-agent install " + + "--server … --enrollment-token …", + ) + } + exePath, err := os.Executable() if err != nil { return fmt.Errorf("cannot resolve current executable path: %w", err) @@ -135,9 +157,42 @@ func newEnrollURLCmd() *cobra.Command { }, } + cmd.Flags().BoolVar(&preflight, "preflight", false, "validate enrollment URL and print JSON for the macOS URL handler") + return cmd } +type enrollPreflightResponse struct { + Server string `json:"server"` + Token string `json:"token"` + AlreadyEnrolled bool `json:"alreadyEnrolled"` + ConfigDir string `json:"configDir"` +} + +func writeEnrollPreflight( + w io.Writer, + serverURL, enrollmentToken, dir string, + alreadyEnrolled bool, +) error { + payload := enrollPreflightResponse{ + Server: serverURL, + Token: enrollmentToken, + AlreadyEnrolled: alreadyEnrolled, + ConfigDir: dir, + } + + out, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cannot encode enrollment preflight response: %w", err) + } + + if _, err := fmt.Fprintln(w, string(out)); err != nil { + return fmt.Errorf("cannot write enrollment preflight response: %w", err) + } + + return nil +} + // reportIfAlreadyEnrolled prints a success message and returns true when // the local enrollment marker is already present. Deep-link retries must // exit 0 so the macOS URL handler does not show "Enrollment failed". @@ -231,6 +286,7 @@ func newInstallCmd() *cobra.Command { if err != nil { return err } + if already { return nil } diff --git a/contrib/claude/make.md b/contrib/claude/make.md index c6152245b..9ed59ab1e 100644 --- a/contrib/claude/make.md +++ b/contrib/claude/make.md @@ -14,8 +14,10 @@ The project uses a `GNUmakefile` at the root. Builds run with `--jobs=$(nproc)` | `make test-short` | Short tests only | | `make test-bench` | Run benchmarks | | `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) | -| `make lint` | Run all linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `lint-js` | -| `make fmt` | Format Go code (`go fmt ./...`) | +| `make lint` | Run Go + JS linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `lint-js` | +| `make lint-swift` | Opt-in: lint Swift enroll-ui (`swift-fmt` + `swift-lint`; needs Swift + SwiftLint; CI runs this on Linux) | +| `make fmt` | Format Go code | +| `make fmt-swift` | Opt-in: format Swift enroll-ui (`swift format` + SwiftLint `--fix`; needs Swift) | | `make clean` | Remove all build artifacts, `node_modules`, generated files, and coverage | | `make help` | List targets with `##` doc comments | @@ -81,3 +83,6 @@ Individual codegen is driven by `go generate`: | `GOOS` | (host) | Cross-compile target OS | | `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` | | `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` | +| `SWIFTLINTCMD` | `swiftlint` | SwiftLint binary | +| `SWIFTCMD` | `swift` | Swift toolchain binary (`swift format`) | +| `SWIFT_ENROLL_UI` | `cmd/probo-agent/installer/macos/enroll-ui` | Path to the Swift SPM package | diff --git a/contrib/claude/release/probo-agent.md b/contrib/claude/release/probo-agent.md index 80ed51c4a..16e662480 100644 --- a/contrib/claude/release/probo-agent.md +++ b/contrib/claude/release/probo-agent.md @@ -28,9 +28,9 @@ If empty or non-user-facing only, do not release this track. make bin/probo-agent ``` -On macOS and Windows hosts, `make` enables CGO automatically so the -menu bar / tray enrollment helper is included. Linux and FreeBSD builds -stay pure Go (no tray). +On macOS hosts, `make` enables CGO so the menu bar enrollment helper +is included. Windows tray support is pure Go (`CGO_ENABLED=0`). Linux +and FreeBSD builds stay pure Go (no tray). ## Notes @@ -45,27 +45,34 @@ verifies the cosign bundle before installing. The menu bar / tray enrollment flow is **macOS and Windows only**. 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 -the `.pkg` are built on macOS with `CGO_ENABLED=1`. +documented below. Windows release binaries are cross-compiled from +Linux with `CGO_ENABLED=0` (tray is pure Go). macOS release binaries +and the `.pkg` are built on macOS with `CGO_ENABLED=1`. ### macOS `.pkg` (MDM / GUI install) Release and local builds use `cmd/probo-agent/installer/macos/build.sh` (requires macOS, a 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). +toolchain). **Signing is mandatory:** `CODESIGN_IDENTITY` and +`APPLE_TEAM_ID` must be set. The script compiles `Probo Agent.app` +(the headless `probo://` URL handler + privileged helper) from +`cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and +app, optionally signs the product with `INSTALLER_IDENTITY`, and +notarizes/staples when `APPLE_ID` and `APPLE_ID_PASSWORD` are set +(password is stored into a keychain profile; submits use +`--keychain-profile` so the secret is not on `notarytool submit` argv). + +There is no unsigned PKG path and no osascript elevation fallback. +Local testing of browser enrollment requires a Developer ID–signed +build. CLI enrollment without the app uses `sudo probo-agent install`. ```shell -# Local unsigned universal pkg (example) +# Local signed pkg (example) +export CODESIGN_IDENTITY="Developer ID Application: Probo Inc (TEAMID)" +export INSTALLER_IDENTITY="Developer ID Installer: Probo Inc (TEAMID)" +export APPLE_TEAM_ID="TEAMID" + 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 @@ -75,11 +82,29 @@ cmd/probo-agent/installer/macos/build.sh \ --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 +PKG postinstall always installs the global tray LaunchAgent, registers +`probo://`, and installs the privileged helper +(`com.probo.agent.helper`) under `/Library/PrivilegedHelperTools` as +root. The only admin authentication is the normal macOS Installer +prompt for the PKG itself. The LaunchDaemon for `probo-agent run` is +created only after enrollment (`probo-agent install`, deep link, or MDM `/tmp/probo-agent.conf`). +Browser enrollment uses `Probo Agent.app` over XPC to the +PKG-installed helper — no SMJobBless and no admin prompt on the enroll +path. A missing or dead helper surfaces an error asking to reinstall +the package. + +Manual QA checklist (macOS PKG): + +1. Fresh signed PKG: Installer may ask for admin once; deep link enrolls with no further prompt. +2. Repeat deep link on an enrolled device: no prompt, immediate success. +3. After `sudo make -C cmd/probo-agent uninstall`, deep link fails with a clear “reinstall package” error (no osascript). +4. `sudo make -C cmd/probo-agent uninstall` removes daemon, tray, helper, app, and state. +5. MDM `/tmp/probo-agent.conf` postinstall still enrolls without a browser prompt. +6. Notarized PKG passes Gatekeeper; `codesign --verify --deep` succeeds on the app bundle. +7. Unsigned `build-app.sh` / `build.sh` exits with an error requiring `CODESIGN_IDENTITY`. + ### Apple signing secrets (GitHub) The `build-macos` job in `release-probo-agent.yaml` expects the same @@ -97,24 +122,23 @@ the probo GitHub repository (or org) before tagging a release: | `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: +Local notarization uses the same env vars as CI: ```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 +export APPLE_ID="you@example.com" +export APPLE_ID_PASSWORD="app-specific-password" +# optional: NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary (default) ``` Windows enrollment is browser-driven: the console issues a `probo://enroll?server=...&token=...` deep link handled by -`probo-agent enroll-url`. After install, register the protocol for the +`Probo Agent.app` on macOS (PKG-installed helper + XPC) or +`probo-agent enroll-url` on Windows. After install, register the protocol for the current user with `cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user `HKCU` handler pointing at `probo-agent.exe`). The system tray helper -(`probo-agent tray`, CGO build on local Windows hosts) shows enrollment -status; enrollment itself happens in the browser. +(`probo-agent tray`) shows enrollment status; enrollment itself happens +in the browser. Region labels and console URLs for the macOS installer HTML live in `cmd/probo-agent/installer/regions.json`. A Go test keeps US/EU URLs in @@ -165,34 +189,32 @@ Environment variables: Never pass the enrollment token in the curl URL. -## Local dev install +## Local install (macOS PKG) -Build, package, and install a dev binary with `sudo`. Agent state is stored -under `~/.local/share/probo-agent-dev` (`--dir`); release archives are staged -under `~/.cache/probo-agent-dev` so the two trees do not overlap. +Primary loop for testing browser enrollment (app + privileged helper): ```shell -make -C cmd/probo-agent install \ +export CODESIGN_IDENTITY="Developer ID Application: … (TEAMID)" +export INSTALLER_IDENTITY="Developer ID Installer: … (TEAMID)" # optional +export APPLE_TEAM_ID="TEAMID" + +make -C cmd/probo-agent install # uninstall leftovers → build PKG → installer +make -C cmd/probo-agent uninstall # full system teardown (idempotent) +make -C cmd/probo-agent clean # uninstall + wipe dist/ caches / enroll-ui/.build +``` + +`install` always runs `uninstall` first so previous helpers, apps, and +Launch Services registrations cannot shadow the new build. Signing env +vars are required for `pkg` / `install` on Darwin. + +### CLI-only install (no app / helper) + +Binary-only path via `installer/install.sh` (any Unix). State under +`~/.local/share/probo-agent-dev`; staging under `~/.cache/probo-agent-dev`. + +```shell +make -C cmd/probo-agent install-cli \ PROBO_SERVER_URL=https://us.probo.com \ PROBO_ENROLLMENT_TOKEN='…' -``` - -This does not compile Go in `cmd/probo-agent`; it reuses `bin/probo-agent` from -the root `GNUmakefile` (via `make bin/probo-agent`, invoked automatically when -needed), stages it into a local release archive, then runs `installer/install.sh` -against that `file://` archive with `PROBO_AGENT_RELEASE_TAG=probo-agent/dev`, -skips checksum verification, installs the binary to `/usr/local/bin/probo-agent`, -and passes `--skip-service` and `--dir ~/.local/share/probo-agent-dev` by default -(`INSTALL_ARGS` overrides). - -Run the agent in the foreground against the same dev state directory: - -```shell make -C cmd/probo-agent run ``` - -Remove dev artifacts: - -```shell -make -C cmd/probo-agent clean -``` diff --git a/pkg/deviceagent/elevate/elevate.go b/pkg/deviceagent/elevate/elevate.go index 09eb3b9a4..63c222ac1 100644 --- a/pkg/deviceagent/elevate/elevate.go +++ b/pkg/deviceagent/elevate/elevate.go @@ -32,6 +32,12 @@ type InstallOptions struct { ConfigDir string // agent config / keystore dir (--dir) } +// UninstallOptions configures an elevated probo-agent uninstall invocation. +type UninstallOptions struct { + ExePath string + ConfigDir string +} + func commandError(out []byte, err error) error { if err == nil { return nil diff --git a/pkg/deviceagent/elevate/elevate_darwin.go b/pkg/deviceagent/elevate/elevate_darwin.go index 78c821b74..4ff5715bc 100644 --- a/pkg/deviceagent/elevate/elevate_darwin.go +++ b/pkg/deviceagent/elevate/elevate_darwin.go @@ -22,51 +22,22 @@ package elevate -import ( - "fmt" - "os/exec" - "strings" +import "errors" - "go.probo.inc/probo/pkg/deviceagent/checks" +// ErrPrivilegedHelperRequired is returned when a non-root process asks for +// elevation on macOS. Browser enrollment must go through the signed +// Probo Agent.app XPC helper (installed by the PKG); CLI install/uninstall +// require sudo. +var ErrPrivilegedHelperRequired = errors.New( + "macOS elevation requires the signed Probo Agent.app privileged helper " + + "(browser enroll via PKG-installed helper) or sudo " + + "(CLI: sudo probo-agent install|uninstall)", ) -func runElevatedInstall(opts InstallOptions, enrollmentToken string) error { - parts := []string{ - shellQuote(opts.ExePath), - "install", - "--server", - shellQuote(opts.ServerURL), - "--enrollment-token", - shellQuote(enrollmentToken), - } - if opts.ConfigDir != "" { - parts = append(parts, "--dir", shellQuote(opts.ConfigDir)) - } - - shellCmd := strings.Join(parts, " ") - - script := fmt.Sprintf( - `do shell script %s with administrator privileges`, - applescriptQuote(shellCmd), - ) - - candidates := checks.CommandCandidates("osascript") - if len(candidates) == 0 { - return fmt.Errorf("command %q not available at expected absolute path", "osascript") - } - - out, err := exec.Command(candidates[0], "-e", script).CombinedOutput() - - return commandError(out, err) +func runElevatedInstall(_ InstallOptions, _ string) error { + return ErrPrivilegedHelperRequired } -func shellQuote(v string) string { - return "'" + strings.ReplaceAll(v, "'", `'"'"'`) + "'" -} - -func applescriptQuote(v string) string { - v = strings.ReplaceAll(v, `\`, `\\`) - v = strings.ReplaceAll(v, `"`, `\"`) - - return `"` + v + `"` +func runElevatedUninstall(_ UninstallOptions) error { + return ErrPrivilegedHelperRequired } diff --git a/pkg/deviceagent/elevate/elevate_darwin_test.go b/pkg/deviceagent/elevate/elevate_darwin_test.go new file mode 100644 index 000000000..ce07c8372 --- /dev/null +++ b/pkg/deviceagent/elevate/elevate_darwin_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package elevate + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunElevatedInstallRequiresPrivilegedHelper(t *testing.T) { + t.Parallel() + + err := RunElevatedInstall( + "/usr/local/bin/probo-agent", + "https://example.com", + "token", + "/var/lib/probo-agent", + ) + require.ErrorIs(t, err, ErrPrivilegedHelperRequired) +} + +func TestRunElevatedUninstallRequiresPrivilegedHelper(t *testing.T) { + t.Parallel() + + err := RunElevatedUninstall("/usr/local/bin/probo-agent", "/var/lib/probo-agent") + require.ErrorIs(t, err, ErrPrivilegedHelperRequired) +} diff --git a/pkg/deviceagent/elevate/elevate_windows.go b/pkg/deviceagent/elevate/elevate_windows.go index ba61d81e1..3aff83f6a 100644 --- a/pkg/deviceagent/elevate/elevate_windows.go +++ b/pkg/deviceagent/elevate/elevate_windows.go @@ -68,3 +68,36 @@ func runElevatedInstall(opts InstallOptions, enrollmentToken string) error { return commandError(out, err) } + +func runElevatedUninstall(opts UninstallOptions) error { + args := []string{"uninstall"} + if opts.ConfigDir != "" { + args = append(args, "--dir", opts.ConfigDir) + } + + argList := make([]string, len(args)) + for i, arg := range args { + argList[i] = "'" + escapePowerShellSingleQuoted(arg) + "'" + } + + script := fmt.Sprintf( + `$p = Start-Process -FilePath %s -ArgumentList @(%s) -Verb RunAs -Wait -PassThru; if ($p.ExitCode -ne 0) { exit $p.ExitCode }`, + "'"+escapePowerShellSingleQuoted(opts.ExePath)+"'", + strings.Join(argList, ","), + ) + + candidates := checks.CommandCandidates("powershell.exe") + if len(candidates) == 0 { + return fmt.Errorf("command %q not available at expected absolute path", "powershell.exe") + } + + out, err := exec.Command( + candidates[0], + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ).CombinedOutput() + + return commandError(out, err) +} diff --git a/pkg/deviceagent/elevate/uninstall_stub.go b/pkg/deviceagent/elevate/uninstall_stub.go new file mode 100644 index 000000000..9782dd507 --- /dev/null +++ b/pkg/deviceagent/elevate/uninstall_stub.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !darwin && !windows + +package elevate + +import "errors" + +func RunElevatedUninstall(_ string, _ string) error { + return errors.New("elevated uninstall is only supported on macOS and Windows") +} diff --git a/pkg/deviceagent/elevate/uninstall_stub_test.go b/pkg/deviceagent/elevate/uninstall_stub_test.go new file mode 100644 index 000000000..5a3c85c62 --- /dev/null +++ b/pkg/deviceagent/elevate/uninstall_stub_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package elevate + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunElevatedUninstallUnsupported(t *testing.T) { + t.Parallel() + + err := RunElevatedUninstall("/usr/local/bin/probo-agent", "/var/lib/probo-agent") + require.Error(t, err) +} diff --git a/pkg/deviceagent/elevate/uninstall_supported.go b/pkg/deviceagent/elevate/uninstall_supported.go new file mode 100644 index 000000000..de672c900 --- /dev/null +++ b/pkg/deviceagent/elevate/uninstall_supported.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin || windows + +package elevate + +func RunElevatedUninstall(exePath string, configDir string) error { + return runElevatedUninstall( + UninstallOptions{ + ExePath: exePath, + ConfigDir: configDir, + }, + ) +} diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go index 9fb7f9f4a..b4294e548 100644 --- a/pkg/deviceagent/service/service_darwin.go +++ b/pkg/deviceagent/service/service_darwin.go @@ -33,6 +33,10 @@ import ( const ( plistPath = "/Library/LaunchDaemons/com.probo.agent.plist" + + helperLabel = "com.probo.agent.helper" + helperPlistPath = "/Library/LaunchDaemons/" + helperLabel + ".plist" + helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel ) const launchdPlistTmpl = ` @@ -83,6 +87,31 @@ func removeLaunchDaemonPlist(path string) error { return nil } +func removeManagedPath(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot remove %s: %w", path, err) + } + + return nil +} + +// removePrivilegedHelper boots out and deletes the PKG-installed XPC helper. +// Missing artifacts are treated as success so uninstall stays idempotent. +func removePrivilegedHelper() error { + _ = exec.Command("launchctl", "bootout", "system/"+helperLabel).Run() + _ = exec.Command("launchctl", "bootout", "system", helperPlistPath).Run() + + if err := removeManagedPath(helperPlistPath); err != nil { + return fmt.Errorf("cannot remove privileged helper plist: %w", err) + } + + if err := removeManagedPath(helperBinaryPath); err != nil { + return fmt.Errorf("cannot remove privileged helper binary: %w", err) + } + + return nil +} + // Install writes and boots the launchd plist. func Install(cfg Config) error { if cfg.ExePath == "" { @@ -126,9 +155,18 @@ func Install(cfg Config) error { return nil } -// Uninstall bootouts and removes the launchd plist. +// Uninstall bootouts and removes the agent LaunchDaemon and the privileged +// XPC helper installed by the macOS PKG. func Uninstall(cfg Config) error { _ = cfg - return removeLaunchDaemonPlist(plistPath) + if err := removeLaunchDaemonPlist(plistPath); err != nil { + return err + } + + if err := removePrivilegedHelper(); err != nil { + return fmt.Errorf("cannot remove privileged helper: %w", err) + } + + return nil }