Install macOS helper from PKG for XPC enroll
Browser enrollment used osascript on every elevate. Ship a signed privileged helper installed at PKG time so probo:// can enroll over XPC with no second admin prompt. Add make install/uninstall/clean for local PKG test loops, and show alerts only on failure. Mirror the Go lint path for the macOS SPM package: Make targets, root configs, and a Linux CI job. Keep checks syntax-only so they do not need a macOS SDK. Format the existing sources so the new gates start clean. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
35
.github/workflows/make.yaml
vendored
35
.github/workflows/make.yaml
vendored
@@ -380,6 +380,41 @@ jobs:
|
|||||||
reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true
|
reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true
|
||||||
done
|
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:
|
test:
|
||||||
name: "test"
|
name: "test"
|
||||||
runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache"
|
runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.cache/
|
||||||
.turbo
|
.turbo
|
||||||
.vscode
|
.vscode
|
||||||
.cursor/*
|
.cursor/*
|
||||||
|
|||||||
28
.swift-format
Normal file
28
.swift-format
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
.swiftlint.yml
Normal file
30
.swiftlint.yml
Normal file
@@ -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
|
||||||
33
GNUmakefile
33
GNUmakefile
@@ -17,6 +17,13 @@ SYFT ?= syft
|
|||||||
TAIL ?= tail
|
TAIL ?= tail
|
||||||
ECHO ?= echo
|
ECHO ?= echo
|
||||||
GOLINTCMD ?= golangci-lint
|
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_FLAGS?=
|
||||||
DOCKER_BUILD= DOCKER_BUILDKIT=1 $(DOCKER) build $(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_BIN= bin/probo-agent
|
||||||
PROBO_AGENT_SRC= ./cmd/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_TARGET_OS= $(if $(GOOS),$(GOOS),$(shell $(GO) env GOOS))
|
||||||
PROBO_AGENT_CGO= 0
|
PROBO_AGENT_CGO= 0
|
||||||
ifeq ($(PROBO_AGENT_TARGET_OS),darwin)
|
ifeq ($(PROBO_AGENT_TARGET_OS),darwin)
|
||||||
PROBO_AGENT_CGO= 1
|
PROBO_AGENT_CGO= 1
|
||||||
endif
|
endif
|
||||||
ifeq ($(PROBO_AGENT_TARGET_OS),windows)
|
|
||||||
PROBO_AGENT_CGO= 1
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifdef WITH_APPS
|
ifdef WITH_APPS
|
||||||
GENERATED += relay
|
GENERATED += relay
|
||||||
@@ -120,6 +124,19 @@ lint-go: vet go-fmt go-fix go-lint
|
|||||||
lint-js:
|
lint-js:
|
||||||
$(NPM) run lint
|
$(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
|
.PHONY: vet
|
||||||
vet: generate embed
|
vet: generate embed
|
||||||
$(GO_VET) ./...
|
$(GO_VET) ./...
|
||||||
@@ -393,6 +410,14 @@ fmt: fmt-go ## Format Go code
|
|||||||
fmt-go: ## Format Go code
|
fmt-go: ## Format Go code
|
||||||
go fmt ./...
|
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
|
.PHONY: clean
|
||||||
clean: ## Clean the project (node_modules and build artifacts)
|
clean: ## Clean the project (node_modules and build artifacts)
|
||||||
$(RM) -rf bin/*
|
$(RM) -rf bin/*
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ documented in this file.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## [0.1.1] - 2026-06-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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
|
CP ?= cp
|
||||||
MKDIR ?= mkdir -p
|
MKDIR ?= mkdir -p
|
||||||
|
RMRF ?= rm -rf
|
||||||
SUDO ?= sudo
|
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
|
STATE_DIR= $(HOME)/.local/share/probo-agent-dev
|
||||||
CACHE_ROOT= $(HOME)/.cache/probo-agent-dev
|
CACHE_ROOT= $(HOME)/.cache/probo-agent-dev
|
||||||
DEV_TAG= probo-agent/dev
|
DEV_TAG= probo-agent/dev
|
||||||
BINARY= /usr/local/bin/probo-agent
|
BINARY= /usr/local/bin/probo-agent
|
||||||
RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG)
|
RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG)
|
||||||
INSTALL_SCRIPT= installer/install.sh
|
INSTALL_SCRIPT= installer/install.sh
|
||||||
REPO_ROOT= ../..
|
MACOS_BUILD_SCRIPT= installer/macos/build.sh
|
||||||
PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent
|
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_S:= $(shell uname -s)
|
||||||
UNAME_M:= $(shell uname -m)
|
UNAME_M:= $(shell uname -m)
|
||||||
@@ -26,12 +48,19 @@ endif
|
|||||||
|
|
||||||
ifeq ($(UNAME_M),x86_64)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
ARCH_LABEL= 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)
|
else ifeq ($(UNAME_M),arm64)
|
||||||
ARCH_LABEL= arm64
|
ARCH_LABEL= arm64
|
||||||
|
BUILD_ARCH= arm64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH_LABEL= arm64
|
ARCH_LABEL= arm64
|
||||||
|
BUILD_ARCH= arm64
|
||||||
else
|
else
|
||||||
ARCH_LABEL=
|
ARCH_LABEL=
|
||||||
|
BUILD_ARCH=
|
||||||
endif
|
endif
|
||||||
|
|
||||||
AGENT_DIR= probo-agent_$(OS_LABEL)_$(ARCH_LABEL)
|
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)
|
STAGING_DIR= $(CACHE_ROOT)/staging/$(AGENT_DIR)
|
||||||
BUILD_BINARY= $(STAGING_DIR)/probo-agent
|
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_ARGS?= --skip-service --dir "$(STATE_DIR)"
|
||||||
INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \
|
INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \
|
||||||
PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \
|
PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \
|
||||||
PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \
|
PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \
|
||||||
PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \
|
PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \
|
||||||
PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \
|
PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \
|
||||||
PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)"
|
PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)"
|
||||||
|
|
||||||
.PHONY: all install run clean
|
.PHONY: help all pkg install uninstall clean clean-build install-cli run
|
||||||
all: install
|
.PHONY: $(PROBO_AGENT_BIN)
|
||||||
|
|
||||||
install: $(ARCHIVE_PATH)
|
all: help
|
||||||
$(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS)
|
|
||||||
|
|
||||||
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)"
|
$(SUDO) "$(BINARY)" run --dir "$(STATE_DIR)"
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf "$(STATE_DIR)" "$(CACHE_ROOT)" "$(BINARY)"
|
|
||||||
|
|
||||||
$(ARCHIVE_PATH): $(BUILD_BINARY)
|
$(ARCHIVE_PATH): $(BUILD_BINARY)
|
||||||
$(MKDIR) "$(RELEASE_DIR)"
|
$(MKDIR) "$(RELEASE_DIR)"
|
||||||
tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)"
|
tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)"
|
||||||
|
|||||||
80
cmd/probo-agent/enroll_url_test.go
Normal file
80
cmd/probo-agent/enroll_url_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
@@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@)
|
@@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@)
|
||||||
@@HOST_ARCHS@@ host arch filter used by Installer.app
|
@@HOST_ARCHS@@ host arch filter used by Installer.app
|
||||||
(e.g. arm64 or arm64,x86_64 for universal)
|
(e.g. arm64 or arm64,x86_64 for universal)
|
||||||
|
@@IDENTIFIER@@ package identifier (default com.probo.agent)
|
||||||
-->
|
-->
|
||||||
<installer-gui-script minSpecVersion="2">
|
<installer-gui-script minSpecVersion="2">
|
||||||
<title>Probo Device Posture Agent @@VERSION@@</title>
|
<title>Probo Device Posture Agent @@VERSION@@</title>
|
||||||
@@ -38,20 +39,20 @@
|
|||||||
<license file="license.txt" mime-type="text/plain"/>
|
<license file="license.txt" mime-type="text/plain"/>
|
||||||
<conclusion file="conclusion.html" mime-type="text/html"/>
|
<conclusion file="conclusion.html" mime-type="text/html"/>
|
||||||
|
|
||||||
<pkg-ref id="com.getprobo.agent"
|
<pkg-ref id="@@IDENTIFIER@@"
|
||||||
version="@@VERSION@@"
|
version="@@VERSION@@"
|
||||||
onConclusion="none">probo-agent-component.pkg</pkg-ref>
|
onConclusion="none">probo-agent-component.pkg</pkg-ref>
|
||||||
|
|
||||||
<choices-outline>
|
<choices-outline>
|
||||||
<line choice="default">
|
<line choice="default">
|
||||||
<line choice="com.getprobo.agent"/>
|
<line choice="@@IDENTIFIER@@"/>
|
||||||
</line>
|
</line>
|
||||||
</choices-outline>
|
</choices-outline>
|
||||||
|
|
||||||
<choice id="default"/>
|
<choice id="default"/>
|
||||||
<choice id="com.getprobo.agent"
|
<choice id="@@IDENTIFIER@@"
|
||||||
title="Probo Device Posture Agent"
|
title="Probo Device Posture Agent"
|
||||||
description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment.">
|
description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment.">
|
||||||
<pkg-ref id="com.getprobo.agent"/>
|
<pkg-ref id="@@IDENTIFIER@@"/>
|
||||||
</choice>
|
</choice>
|
||||||
</installer-gui-script>
|
</installer-gui-script>
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
<li>Installs <code>Probo Agent.app</code> to
|
<li>Installs <code>Probo Agent.app</code> to
|
||||||
<code>/Applications</code> so browser enrollment deep links
|
<code>/Applications</code> so browser enrollment deep links
|
||||||
(<code>probo://</code>) work.</li>
|
(<code>probo://</code>) work.</li>
|
||||||
|
<li>Installs the privileged helper
|
||||||
|
<code>com.probo.agent.helper</code> so browser enrollment can
|
||||||
|
finish without a second admin password prompt.</li>
|
||||||
<li>Registers the menu bar helper LaunchAgent
|
<li>Registers the menu bar helper LaunchAgent
|
||||||
<code>com.probo.agent.tray</code> in
|
<code>com.probo.agent.tray</code> in
|
||||||
<code>/Library/LaunchAgents</code>.</li>
|
<code>/Library/LaunchAgents</code>.</li>
|
||||||
|
|||||||
@@ -11,28 +11,26 @@
|
|||||||
# --output PATH Output .pkg path. Defaults to
|
# --output PATH Output .pkg path. Defaults to
|
||||||
# dist/probo-agent_${VER}_darwin_${ARCH}.pkg.
|
# dist/probo-agent_${VER}_darwin_${ARCH}.pkg.
|
||||||
#
|
#
|
||||||
# Optional environment variables (auditor-mode compatible):
|
# Required environment variables:
|
||||||
# CODESIGN_IDENTITY Developer ID Application identity. When
|
# CODESIGN_IDENTITY Developer ID Application identity. Signs the
|
||||||
# set, signs the agent binary and Probo
|
# agent binary, Probo Agent.app, and embedded helper.
|
||||||
# Agent.app with hardened runtime before
|
# APPLE_TEAM_ID Apple Developer Team ID (helper client requirement).
|
||||||
# packaging.
|
#
|
||||||
|
# Optional (auditor-mode compatible):
|
||||||
# INSTALLER_IDENTITY Developer ID Installer identity. When
|
# INSTALLER_IDENTITY Developer ID Installer identity. When
|
||||||
# set, passes --sign to productbuild.
|
# set, passes --sign to productbuild.
|
||||||
# APPLE_ID Apple ID for notarytool store-credentials.
|
# APPLE_ID Apple ID for notarytool store-credentials.
|
||||||
# APPLE_ID_PASSWORD App-specific password; used only to
|
# APPLE_ID_PASSWORD App-specific password; used only to
|
||||||
# populate a keychain profile (not passed
|
# populate a keychain profile (not passed
|
||||||
# to long-lived notarytool submit).
|
# to long-lived notarytool submit).
|
||||||
# APPLE_TEAM_ID Apple Developer Team ID.
|
# NOTARYTOOL_KEYCHAIN_PROFILE Keychain profile name for store/submit.
|
||||||
# NOTARYTOOL_KEYCHAIN_PROFILE Existing notarytool keychain profile.
|
# Defaults to probo-agent-notary.
|
||||||
# Defaults to probo-agent-notary when
|
|
||||||
# storing from APPLE_ID / APPLE_ID_PASSWORD.
|
|
||||||
#
|
#
|
||||||
# Notarization is enabled when NOTARYTOOL_KEYCHAIN_PROFILE is set, or
|
# Notarization is enabled when APPLE_ID and APPLE_ID_PASSWORD are both
|
||||||
# when APPLE_ID and APPLE_ID_PASSWORD are both set (APPLE_TEAM_ID also
|
# set. INSTALLER_IDENTITY is then required. The script stores credentials
|
||||||
# required). CODESIGN_IDENTITY and INSTALLER_IDENTITY are then required.
|
# into the keychain profile, then notarizes and staples the .app before
|
||||||
# The script stores credentials into the keychain profile when a
|
# packaging and the signed .pkg via --keychain-profile so the password
|
||||||
# password is provided, then notarizes and staples the .app before
|
# is not on submit argv for the long --wait.
|
||||||
# packaging and the signed .pkg via --keychain-profile.
|
|
||||||
#
|
#
|
||||||
# Must run on macOS: pkgbuild, productbuild, and swift build are
|
# Must run on macOS: pkgbuild, productbuild, and swift build are
|
||||||
# Apple-only tools. The build also compiles Probo Agent.app (the
|
# Apple-only tools. The build also compiles Probo Agent.app (the
|
||||||
@@ -47,13 +45,13 @@ BINARY=""
|
|||||||
ARCH=""
|
ARCH=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
OUTPUT=""
|
OUTPUT=""
|
||||||
IDENTIFIER="com.getprobo.agent"
|
IDENTIFIER="com.probo.agent"
|
||||||
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
|
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||||
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
|
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
|
||||||
APPLE_ID="${APPLE_ID:-}"
|
APPLE_ID="${APPLE_ID:-}"
|
||||||
APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}"
|
APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}"
|
||||||
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
|
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
|
||||||
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-}"
|
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-probo-agent-notary}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
||||||
@@ -101,28 +99,42 @@ case "${ARCH}" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# --arch universal advertises both hostArchitectures in Distribution.xml.
|
# --arch sets hostArchitectures in Distribution.xml. Refuse a binary that
|
||||||
# Refuse a single-slice binary so Installer cannot install on a CPU the
|
# lacks the advertised slice(s) so Installer cannot install on a CPU the
|
||||||
# agent cannot run on.
|
# agent cannot run on.
|
||||||
if [ "${ARCH}" = "universal" ]; then
|
if ! command -v lipo >/dev/null 2>&1; then
|
||||||
if ! command -v lipo >/dev/null 2>&1; then
|
echo "error: lipo is required to validate --binary architecture (run on macOS)" >&2
|
||||||
echo "error: lipo is required to validate a universal --binary (run on macOS)" >&2
|
exit 1
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
|
|
||||||
has_arm64=false
|
|
||||||
has_x86_64=false
|
|
||||||
for arch_slice in ${BINARY_ARCHS}; do
|
|
||||||
case "${arch_slice}" in
|
|
||||||
arm64) has_arm64=true ;;
|
|
||||||
x86_64) has_x86_64=true ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
|
|
||||||
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
fi
|
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
|
if [ -z "${VERSION}" ]; then
|
||||||
VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")"
|
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
|
echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
notarize_enabled=false
|
||||||
if [ -n "${NOTARYTOOL_KEYCHAIN_PROFILE}" ]; then
|
if [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ]; then
|
||||||
notarize_enabled=true
|
|
||||||
elif [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ] && [ -n "${APPLE_TEAM_ID}" ]; then
|
|
||||||
NOTARYTOOL_KEYCHAIN_PROFILE="probo-agent-notary"
|
|
||||||
notarize_enabled=true
|
notarize_enabled=true
|
||||||
fi
|
fi
|
||||||
if [ "${notarize_enabled}" = true ]; then
|
if [ "${notarize_enabled}" = true ] && [ -z "${INSTALLER_IDENTITY}" ]; then
|
||||||
if [ -z "${CODESIGN_IDENTITY}" ]; then
|
echo "error: notarization requires INSTALLER_IDENTITY" >&2
|
||||||
echo "error: notarization requires CODESIGN_IDENTITY" >&2
|
exit 2
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
if [ -z "${INSTALLER_IDENTITY}" ]; then
|
|
||||||
echo "error: notarization requires INSTALLER_IDENTITY" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sign_macho() {
|
sign_macho() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
if [ -z "${CODESIGN_IDENTITY}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
codesign \
|
codesign \
|
||||||
--force \
|
--force \
|
||||||
--options runtime \
|
--options runtime \
|
||||||
@@ -175,8 +183,15 @@ sign_macho() {
|
|||||||
|
|
||||||
sign_app_bundle() {
|
sign_app_bundle() {
|
||||||
local app_path="$1"
|
local app_path="$1"
|
||||||
if [ -z "${CODESIGN_IDENTITY}" ]; then
|
local helper_path="${app_path}/Contents/Library/LaunchServices/com.probo.agent.helper"
|
||||||
return 0
|
if [ -x "${helper_path}" ]; then
|
||||||
|
codesign \
|
||||||
|
--force \
|
||||||
|
--options runtime \
|
||||||
|
--timestamp \
|
||||||
|
--sign "${CODESIGN_IDENTITY}" \
|
||||||
|
"${helper_path}"
|
||||||
|
codesign --verify --verbose=2 "${helper_path}"
|
||||||
fi
|
fi
|
||||||
codesign \
|
codesign \
|
||||||
--force \
|
--force \
|
||||||
@@ -194,11 +209,8 @@ sign_app_bundle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_notarytool_credentials() {
|
ensure_notarytool_credentials() {
|
||||||
if [ -z "${APPLE_ID_PASSWORD}" ]; then
|
if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_ID_PASSWORD}" ]; then
|
||||||
return 0
|
echo "error: APPLE_ID and APPLE_ID_PASSWORD are required to store notarytool credentials" >&2
|
||||||
fi
|
|
||||||
if [ -z "${APPLE_ID}" ]; then
|
|
||||||
echo "error: APPLE_ID_PASSWORD requires APPLE_ID to store notarytool credentials" >&2
|
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
# Password appears on argv only for this short-lived store. Submits
|
# Password appears on argv only for this short-lived store. Submits
|
||||||
@@ -327,6 +339,7 @@ sed \
|
|||||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||||
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
|
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
|
||||||
-e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \
|
-e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \
|
||||||
|
-e "s|@@IDENTIFIER@@|${IDENTIFIER}|g" \
|
||||||
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
|
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
|
||||||
|
|
||||||
mkdir -p "$(dirname "${OUTPUT}")"
|
mkdir -p "$(dirname "${OUTPUT}")"
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
# SwiftPM
|
||||||
.build/
|
.build/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
|
||||||
|
# Rendered by build-app.sh from *.tmpl (do not commit)
|
||||||
|
Shared/*.generated.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
_ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.probo.agent.helper</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Probo Agent Helper</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>@@VERSION@@</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>@@VERSION@@</string>
|
||||||
|
<key>SMAuthorizedClients</key>
|
||||||
|
<array>
|
||||||
|
<string>@@CLIENT_DESIGNATED_REQUIREMENT@@</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.probo.agent.helper</string>
|
||||||
|
<key>MachServices</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.probo.agent.helper</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.probo.agent.url-handler</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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()
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
Placeholders are substituted by build-app.sh:
|
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
|
||||||
-->
|
-->
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>probo-agent-url-handler</string>
|
<string>probo-agent-url-handler</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.getprobo.agent.url-handler</string>
|
<string>com.probo.agent.url-handler</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Probo Agent</string>
|
<string>Probo Agent</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -41,5 +42,10 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>SMPrivilegedExecutables</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.probo.agent.helper</key>
|
||||||
|
<string>@@HELPER_DESIGNATED_REQUIREMENT@@</string>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,11 +4,43 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "probo-agent-url-handler",
|
name: "probo-agent-url-handler",
|
||||||
platforms: [
|
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: [
|
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(
|
.executableTarget(
|
||||||
name: "probo-agent-url-handler",
|
name: "probo-agent-url-handler",
|
||||||
|
dependencies: ["HelperClient", "ProboAgentShared"],
|
||||||
path: "URLHandlerSources"
|
path: "URLHandlerSources"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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<audit_token_t>.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)"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Generated by build-app.sh — do not edit.
|
||||||
|
enum ProboAgentHelperVersion {
|
||||||
|
static let value = "@@VERSION@@"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Generated by build-app.sh — do not edit.
|
||||||
|
enum ProboAgentSigningConstants {
|
||||||
|
static let teamID: String? = @@TEAM_ID_OPTION@@
|
||||||
|
}
|
||||||
@@ -1,61 +1,10 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import HelperClient
|
||||||
// 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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
||||||
private var didReceiveURL = false
|
private var didReceiveURL = false
|
||||||
|
private var idleTimer: Timer?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -69,127 +18,89 @@ private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in
|
NSLog("probo-agent url-handler: launched")
|
||||||
if !self.didReceiveURL {
|
idleTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in
|
||||||
NSApp.terminate(nil)
|
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(
|
@objc private func handleGetURLEvent(
|
||||||
_ event: NSAppleEventDescriptor,
|
_ event: NSAppleEventDescriptor,
|
||||||
withReplyEvent replyEvent: NSAppleEventDescriptor
|
withReplyEvent replyEvent: NSAppleEventDescriptor
|
||||||
) {
|
) {
|
||||||
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
|
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
|
||||||
reportFailure("Enrollment link is missing.")
|
presentFailure("Enrollment link is missing.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !didReceiveURL else { return }
|
beginEnrollmentIfNeeded(rawURL: rawURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginEnrollmentIfNeeded(rawURL: String) {
|
||||||
|
guard !didReceiveURL else { return }
|
||||||
didReceiveURL = true
|
didReceiveURL = true
|
||||||
|
idleTimer?.invalidate()
|
||||||
|
idleTimer = nil
|
||||||
runEnrollment(for: rawURL)
|
runEnrollment(for: rawURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runEnrollment(for rawURL: String) {
|
private func runEnrollment(for rawURL: String) {
|
||||||
let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning()
|
NSLog("probo-agent url-handler: starting enrollment")
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
let process = Process()
|
do {
|
||||||
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
|
NSLog("probo-agent url-handler: preflight…")
|
||||||
process.arguments = ["enroll-url", rawURL]
|
let preflight = try EnrollmentFlow.runPreflight(rawURL: rawURL)
|
||||||
|
if preflight.alreadyEnrolled {
|
||||||
let output = Pipe()
|
NSLog("probo-agent url-handler: already enrolled")
|
||||||
process.standardOutput = output
|
DispatchQueue.main.async {
|
||||||
process.standardError = output
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
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()
|
|
||||||
return
|
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 {
|
DispatchQueue.main.async {
|
||||||
try process.run()
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
readDone.signal()
|
NSLog(
|
||||||
|
"probo-agent url-handler: enrollment failed: %@",
|
||||||
|
error.localizedDescription
|
||||||
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.reportFailure(
|
self.presentFailure(error.localizedDescription)
|
||||||
self.sanitizedFailureMessage(error.localizedDescription),
|
|
||||||
shouldNotifyWizard: shouldNotifyWizard
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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()) {
|
private func presentFailure(_ message: String) {
|
||||||
if shouldNotifyWizard {
|
presentAlert(title: "Enrollment failed", message: message, style: .warning)
|
||||||
EnrollmentCallbackStore.writeStatus(state: .failure, message: message)
|
|
||||||
NSApp.terminate(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sanitizedFailureMessage(_ raw: String?) -> String {
|
private func presentAlert(title: String, message: String, style: NSAlert.Style) {
|
||||||
guard let raw else {
|
// LSUIElement / .accessory apps otherwise show alerts that never appear.
|
||||||
return "Enrollment failed. Please try again."
|
NSApp.setActivationPolicy(.regular)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.alertStyle = style
|
||||||
alert.runModal()
|
alert.runModal()
|
||||||
|
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Build Probo Agent.app — a headless macOS app bundle that registers
|
# Build Probo Agent.app — headless macOS app bundle with probo:// handler
|
||||||
# the probo:// URL scheme and forwards enrollment links to probo-agent.
|
# and embedded privileged helper (installed by PKG postinstall).
|
||||||
#
|
#
|
||||||
# Required arguments:
|
# Required arguments:
|
||||||
# --arch amd64, arm64, or universal
|
# --arch amd64, arm64, or universal
|
||||||
# --version Agent version, e.g. 0.1.0
|
# --version Agent version, e.g. 0.1.0
|
||||||
# --output Parent directory; creates "Probo Agent.app" inside it
|
# --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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -18,7 +22,10 @@ ARCH=""
|
|||||||
VERSION=""
|
VERSION=""
|
||||||
OUTPUT=""
|
OUTPUT=""
|
||||||
APP_NAME="Probo Agent.app"
|
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() {
|
usage() {
|
||||||
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
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
|
echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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
|
pushd "${SCRIPT_DIR}" >/dev/null
|
||||||
swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}"
|
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
|
||||||
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
|
--scratch-path "${BUILD_DIR}/swift" \
|
||||||
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}"
|
--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
|
popd >/dev/null
|
||||||
|
|
||||||
if [ ! -x "${BINARY}" ]; then
|
if [ ! -x "${HELPER_BINARY}" ] || [ ! -x "${URL_HANDLER_BINARY}" ]; then
|
||||||
echo "error: release binary not found at ${BINARY}" >&2
|
echo "error: expected release binaries were not produced" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
APP_ROOT="${OUTPUT}/${APP_NAME}"
|
APP_ROOT="${OUTPUT}/${APP_NAME}"
|
||||||
CONTENTS="${APP_ROOT}/Contents"
|
CONTENTS="${APP_ROOT}/Contents"
|
||||||
MACOS="${CONTENTS}/MacOS"
|
MACOS="${CONTENTS}/MacOS"
|
||||||
|
LAUNCH_SERVICES="${CONTENTS}/Library/LaunchServices"
|
||||||
|
LAUNCH_DAEMONS="${CONTENTS}/Library/LaunchDaemons"
|
||||||
PLIST="${CONTENTS}/Info.plist"
|
PLIST="${CONTENTS}/Info.plist"
|
||||||
|
EMBEDDED_HELPER="${LAUNCH_SERVICES}/${HELPER_LABEL}"
|
||||||
|
EMBEDDED_LAUNCHD="${LAUNCH_DAEMONS}/${HELPER_LABEL}.plist"
|
||||||
|
|
||||||
rm -rf "${APP_ROOT}"
|
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 \
|
sed \
|
||||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||||
|
-e "s|@@HELPER_DESIGNATED_REQUIREMENT@@|${HELPER_REQUIREMENT}|g" \
|
||||||
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
|
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
|
||||||
|
|
||||||
if ! plutil -lint "${PLIST}" >/dev/null; then
|
if ! plutil -lint "${PLIST}" >/dev/null; then
|
||||||
@@ -98,4 +195,18 @@ if ! grep -q '<string>probo</string>' "${PLIST}"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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}"
|
echo "Built ${APP_ROOT}"
|
||||||
|
|||||||
26
cmd/probo-agent/installer/macos/reinstall.sh
Executable file
26
cmd/probo-agent/installer/macos/reinstall.sh
Executable file
@@ -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."
|
||||||
@@ -22,6 +22,10 @@ STATE_DIR="/var/lib/probo-agent"
|
|||||||
RUN_DIR="/var/run/probo-agent"
|
RUN_DIR="/var/run/probo-agent"
|
||||||
CONF_FILE="/tmp/probo-agent.conf"
|
CONF_FILE="/tmp/probo-agent.conf"
|
||||||
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.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}"
|
||||||
|
APP_PATH="/Applications/Probo Agent.app"
|
||||||
TRAY_LABEL="com.probo.agent.tray"
|
TRAY_LABEL="com.probo.agent.tray"
|
||||||
TRAY_PLIST_NAME="${TRAY_LABEL}.plist"
|
TRAY_PLIST_NAME="${TRAY_LABEL}.plist"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -133,11 +137,10 @@ register_tray_launchagent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
register_enrollment_url_scheme() {
|
register_enrollment_url_scheme() {
|
||||||
local app_path lsregister
|
local lsregister
|
||||||
|
|
||||||
app_path="/Applications/Probo Agent.app"
|
if [ ! -d "${APP_PATH}" ]; then
|
||||||
if [ ! -d "${app_path}" ]; then
|
echo "warning: ${APP_PATH} not found; cannot register probo:// URL scheme."
|
||||||
echo "warning: ${app_path} not found; cannot register probo:// URL scheme."
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ register_enrollment_url_scheme() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! "${lsregister}" -f "${app_path}"; then
|
if ! "${lsregister}" -f "${APP_PATH}"; then
|
||||||
echo "warning: failed to register probo:// URL scheme."
|
echo "warning: failed to register probo:// URL scheme."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -155,6 +158,62 @@ register_enrollment_url_scheme() {
|
|||||||
echo "Registered probo:// 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}" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${HELPER_LABEL}</string>
|
||||||
|
<key>Program</key>
|
||||||
|
<string>${HELPER_BINARY}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${HELPER_BINARY}</string>
|
||||||
|
</array>
|
||||||
|
<key>MachServices</key>
|
||||||
|
<dict>
|
||||||
|
<key>${HELPER_LABEL}</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.probo.agent.url-handler</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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
|
# Restart a previously enrolled LaunchDaemon after upgrades. Preinstall
|
||||||
# boots it out so the binary can be replaced; without /tmp/probo-agent.conf
|
# boots it out so the binary can be replaced; without /tmp/probo-agent.conf
|
||||||
# enrollment is skipped and nothing else would load it again.
|
# 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."
|
echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! install_privileged_helper; then
|
||||||
|
echo "error: privileged helper installation failed; browser enrollment will not work."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
restart_existing_daemon
|
restart_existing_daemon
|
||||||
register_tray_launchagent
|
register_tray_launchagent
|
||||||
register_enrollment_url_scheme
|
register_enrollment_url_scheme
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ LOG_FILE="/var/log/probo-agent-install.log"
|
|||||||
TRAY_LABEL="com.probo.agent.tray"
|
TRAY_LABEL="com.probo.agent.tray"
|
||||||
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
|
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
|
||||||
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.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}")"
|
mkdir -p "$(dirname "${LOG_FILE}")"
|
||||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||||
@@ -54,6 +57,17 @@ if [ -f "${DAEMON_PLIST}" ]; then
|
|||||||
echo "Booted out LaunchDaemon at ${DAEMON_PLIST}."
|
echo "Booted out LaunchDaemon at ${DAEMON_PLIST}."
|
||||||
fi
|
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
|
if [ -f "${TRAY_PLIST}" ]; then
|
||||||
echo "Existing tray LaunchAgent will be replaced by postinstall."
|
echo "Existing tray LaunchAgent will be replaced by postinstall."
|
||||||
fi
|
fi
|
||||||
|
|||||||
149
cmd/probo-agent/installer/macos/uninstall.sh
Executable file
149
cmd/probo-agent/installer/macos/uninstall.sh
Executable file
@@ -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 ==="
|
||||||
@@ -25,9 +25,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ func newRootCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newEnrollURLCmd() *cobra.Command {
|
func newEnrollURLCmd() *cobra.Command {
|
||||||
|
var preflight bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "enroll-url [url]",
|
Use: "enroll-url [url]",
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
@@ -112,14 +116,32 @@ func newEnrollURLCmd() *cobra.Command {
|
|||||||
|
|
||||||
dir := resolveDir(cmd)
|
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)
|
already, err := reportIfAlreadyEnrolled(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if already {
|
if already {
|
||||||
return nil
|
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()
|
exePath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot resolve current executable path: %w", err)
|
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
|
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
|
// reportIfAlreadyEnrolled prints a success message and returns true when
|
||||||
// the local enrollment marker is already present. Deep-link retries must
|
// the local enrollment marker is already present. Deep-link retries must
|
||||||
// exit 0 so the macOS URL handler does not show "Enrollment failed".
|
// exit 0 so the macOS URL handler does not show "Enrollment failed".
|
||||||
@@ -231,6 +286,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if already {
|
if already {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-short` | Short tests only |
|
||||||
| `make test-bench` | Run benchmarks |
|
| `make test-bench` | Run benchmarks |
|
||||||
| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) |
|
| `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 lint` | Run Go + JS linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `lint-js` |
|
||||||
| `make fmt` | Format Go code (`go fmt ./...`) |
|
| `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 clean` | Remove all build artifacts, `node_modules`, generated files, and coverage |
|
||||||
| `make help` | List targets with `##` doc comments |
|
| `make help` | List targets with `##` doc comments |
|
||||||
|
|
||||||
@@ -81,3 +83,6 @@ Individual codegen is driven by `go generate`:
|
|||||||
| `GOOS` | (host) | Cross-compile target OS |
|
| `GOOS` | (host) | Cross-compile target OS |
|
||||||
| `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` |
|
| `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` |
|
||||||
| `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` |
|
| `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 |
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ If empty or non-user-facing only, do not release this track.
|
|||||||
make bin/probo-agent
|
make bin/probo-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS and Windows hosts, `make` enables CGO automatically so the
|
On macOS hosts, `make` enables CGO so the menu bar enrollment helper
|
||||||
menu bar / tray enrollment helper is included. Linux and FreeBSD builds
|
is included. Windows tray support is pure Go (`CGO_ENABLED=0`). Linux
|
||||||
stay pure Go (no tray).
|
and FreeBSD builds stay pure Go (no tray).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@@ -45,27 +45,34 @@ verifies the cosign bundle before installing.
|
|||||||
The menu bar / tray enrollment flow is **macOS and Windows only**.
|
The menu bar / tray enrollment flow is **macOS and Windows only**.
|
||||||
Linux and FreeBSD use `probo-agent install --server …
|
Linux and FreeBSD use `probo-agent install --server …
|
||||||
--enrollment-token …` from the shell, or the curl-to-sh installer
|
--enrollment-token …` from the shell, or the curl-to-sh installer
|
||||||
documented below. Windows release binaries are
|
documented below. Windows release binaries are cross-compiled from
|
||||||
cross-compiled from Linux with MinGW (CGO). macOS release binaries and
|
Linux with `CGO_ENABLED=0` (tray is pure Go). macOS release binaries
|
||||||
the `.pkg` are built on macOS with `CGO_ENABLED=1`.
|
and the `.pkg` are built on macOS with `CGO_ENABLED=1`.
|
||||||
|
|
||||||
### macOS `.pkg` (MDM / GUI install)
|
### macOS `.pkg` (MDM / GUI install)
|
||||||
|
|
||||||
Release and local builds use
|
Release and local builds use
|
||||||
`cmd/probo-agent/installer/macos/build.sh` (requires macOS, a
|
`cmd/probo-agent/installer/macos/build.sh` (requires macOS, a
|
||||||
pre-built binary — preferably universal via `lipo` — and the Swift
|
pre-built binary — preferably universal via `lipo` — and the Swift
|
||||||
toolchain). The script compiles `Probo Agent.app` (the headless
|
toolchain). **Signing is mandatory:** `CODESIGN_IDENTITY` and
|
||||||
`probo://` URL handler) from
|
`APPLE_TEAM_ID` must be set. The script compiles `Probo Agent.app`
|
||||||
`cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and app
|
(the headless `probo://` URL handler + privileged helper) from
|
||||||
when `CODESIGN_IDENTITY` is set, signs the product with
|
`cmd/probo-agent/installer/macos/enroll-ui/`, signs the binary and
|
||||||
`INSTALLER_IDENTITY`, and notarizes/staples when
|
app, optionally signs the product with `INSTALLER_IDENTITY`, and
|
||||||
`NOTARYTOOL_KEYCHAIN_PROFILE` is set, or when `APPLE_ID`,
|
notarizes/staples when `APPLE_ID` and `APPLE_ID_PASSWORD` are set
|
||||||
`APPLE_ID_PASSWORD`, and `APPLE_TEAM_ID` are set (password is stored
|
(password is stored into a keychain profile; submits use
|
||||||
into a keychain profile; submits use `--keychain-profile` so the secret
|
`--keychain-profile` so the secret is not on `notarytool submit` argv).
|
||||||
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
|
```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=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
|
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
|
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)"
|
--version "$(cat cmd/probo-agent/VERSION)"
|
||||||
```
|
```
|
||||||
|
|
||||||
PKG postinstall always installs the global tray LaunchAgent and
|
PKG postinstall always installs the global tray LaunchAgent, registers
|
||||||
registers `probo://`. The LaunchDaemon for `probo-agent run` is created
|
`probo://`, and installs the privileged helper
|
||||||
only after enrollment (`probo-agent install`, deep link, or MDM
|
(`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`).
|
`/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)
|
### Apple signing secrets (GitHub)
|
||||||
|
|
||||||
The `build-macos` job in `release-probo-agent.yaml` expects the same
|
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_ID_PASSWORD` | App-specific password (stored into a keychain profile; not passed to `submit`) |
|
||||||
| `APPLE_TEAM_ID` | 10-character Team ID |
|
| `APPLE_TEAM_ID` | 10-character Team ID |
|
||||||
|
|
||||||
Local notarization can reuse a pre-stored profile instead of putting the
|
Local notarization uses the same env vars as CI:
|
||||||
password in the environment:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
xcrun notarytool store-credentials probo-agent-notary \
|
export APPLE_ID="you@example.com"
|
||||||
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID"
|
export APPLE_ID_PASSWORD="app-specific-password"
|
||||||
# prompts for the app-specific password once
|
# optional: NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary (default)
|
||||||
export NOTARYTOOL_KEYCHAIN_PROFILE=probo-agent-notary
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows enrollment is browser-driven: the console issues a
|
Windows enrollment is browser-driven: the console issues a
|
||||||
`probo://enroll?server=...&token=...` deep link handled by
|
`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
|
current user with
|
||||||
`cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user
|
`cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user
|
||||||
`HKCU` handler pointing at `probo-agent.exe`). The system tray helper
|
`HKCU` handler pointing at `probo-agent.exe`). The system tray helper
|
||||||
(`probo-agent tray`, CGO build on local Windows hosts) shows enrollment
|
(`probo-agent tray`) shows enrollment status; enrollment itself happens
|
||||||
status; enrollment itself happens in the browser.
|
in the browser.
|
||||||
|
|
||||||
Region labels and console URLs for the macOS installer HTML live in
|
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
|
`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.
|
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
|
Primary loop for testing browser enrollment (app + privileged helper):
|
||||||
under `~/.local/share/probo-agent-dev` (`--dir`); release archives are staged
|
|
||||||
under `~/.cache/probo-agent-dev` so the two trees do not overlap.
|
|
||||||
|
|
||||||
```shell
|
```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_SERVER_URL=https://us.probo.com \
|
||||||
PROBO_ENROLLMENT_TOKEN='…'
|
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
|
make -C cmd/probo-agent run
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove dev artifacts:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
make -C cmd/probo-agent clean
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ type InstallOptions struct {
|
|||||||
ConfigDir string // agent config / keystore dir (--dir)
|
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 {
|
func commandError(out []byte, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -22,51 +22,22 @@
|
|||||||
|
|
||||||
package elevate
|
package elevate
|
||||||
|
|
||||||
import (
|
import "errors"
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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 {
|
func runElevatedInstall(_ InstallOptions, _ string) error {
|
||||||
parts := []string{
|
return ErrPrivilegedHelperRequired
|
||||||
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 shellQuote(v string) string {
|
func runElevatedUninstall(_ UninstallOptions) error {
|
||||||
return "'" + strings.ReplaceAll(v, "'", `'"'"'`) + "'"
|
return ErrPrivilegedHelperRequired
|
||||||
}
|
|
||||||
|
|
||||||
func applescriptQuote(v string) string {
|
|
||||||
v = strings.ReplaceAll(v, `\`, `\\`)
|
|
||||||
v = strings.ReplaceAll(v, `"`, `\"`)
|
|
||||||
|
|
||||||
return `"` + v + `"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
pkg/deviceagent/elevate/elevate_darwin_test.go
Normal file
48
pkg/deviceagent/elevate/elevate_darwin_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -68,3 +68,36 @@ func runElevatedInstall(opts InstallOptions, enrollmentToken string) error {
|
|||||||
|
|
||||||
return commandError(out, err)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
29
pkg/deviceagent/elevate/uninstall_stub.go
Normal file
29
pkg/deviceagent/elevate/uninstall_stub.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
34
pkg/deviceagent/elevate/uninstall_stub_test.go
Normal file
34
pkg/deviceagent/elevate/uninstall_stub_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
32
pkg/deviceagent/elevate/uninstall_supported.go
Normal file
32
pkg/deviceagent/elevate/uninstall_supported.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
plistPath = "/Library/LaunchDaemons/com.probo.agent.plist"
|
plistPath = "/Library/LaunchDaemons/com.probo.agent.plist"
|
||||||
|
|
||||||
|
helperLabel = "com.probo.agent.helper"
|
||||||
|
helperPlistPath = "/Library/LaunchDaemons/" + helperLabel + ".plist"
|
||||||
|
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
|
||||||
)
|
)
|
||||||
|
|
||||||
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -83,6 +87,31 @@ func removeLaunchDaemonPlist(path string) error {
|
|||||||
return nil
|
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.
|
// Install writes and boots the launchd plist.
|
||||||
func Install(cfg Config) error {
|
func Install(cfg Config) error {
|
||||||
if cfg.ExePath == "" {
|
if cfg.ExePath == "" {
|
||||||
@@ -126,9 +155,18 @@ func Install(cfg Config) error {
|
|||||||
return nil
|
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 {
|
func Uninstall(cfg Config) error {
|
||||||
_ = cfg
|
_ = 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user