From d0dd87c6c7e253e6377b3b69d64cb277bd7634af Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Tue, 14 Jul 2026 20:40:18 +0200 Subject: [PATCH] Add probo-agent binary, installer, and CI Signed-off-by: Ludovic Vielle --- .github/workflows/make.yaml | 10 +- .github/workflows/release-probo-agent.yaml | 29 +- GNUmakefile | 17 +- cmd/probo-agent/GNUmakefile | 74 ++++ cmd/probo-agent/desktop.go | 81 ++++ cmd/probo-agent/desktop_stub.go | 31 ++ cmd/probo-agent/installer/install.sh | 365 ++++++++++++++++++ .../installer/macos/Resources/conclusion.html | 29 +- .../installer/macos/Resources/welcome.html | 40 +- cmd/probo-agent/installer/macos/build.sh | 14 +- .../installer/macos/enroll-ui/.gitignore | 2 + .../installer/macos/enroll-ui/Info.plist.tmpl | 45 +++ .../installer/macos/enroll-ui/Package.swift | 15 + .../enroll-ui/URLHandlerSources/main.swift | 201 ++++++++++ .../installer/macos/enroll-ui/build-app.sh | 97 +++++ .../installer/macos/scripts/postinstall | 151 +++++++- .../installer/macos/scripts/preinstall | 9 + cmd/probo-agent/installer/regions.json | 27 ++ .../installer/regions_manifest_test.go | 63 +++ .../installer/windows/register-protocol.ps1 | 32 ++ cmd/probo-agent/main.go | 95 ++++- contrib/claude/release/probo-agent.md | 108 +++++- pkg/deviceagent/agent.go | 26 +- pkg/deviceagent/state_cleanup.go | 111 ++++++ 24 files changed, 1597 insertions(+), 75 deletions(-) create mode 100644 cmd/probo-agent/GNUmakefile create mode 100644 cmd/probo-agent/desktop.go create mode 100644 cmd/probo-agent/desktop_stub.go create mode 100755 cmd/probo-agent/installer/install.sh create mode 100644 cmd/probo-agent/installer/macos/enroll-ui/.gitignore create mode 100644 cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl create mode 100644 cmd/probo-agent/installer/macos/enroll-ui/Package.swift create mode 100644 cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift create mode 100755 cmd/probo-agent/installer/macos/enroll-ui/build-app.sh create mode 100644 cmd/probo-agent/installer/macos/scripts/preinstall create mode 100644 cmd/probo-agent/installer/regions.json create mode 100644 cmd/probo-agent/installer/regions_manifest_test.go create mode 100644 cmd/probo-agent/installer/windows/register-protocol.ps1 create mode 100644 pkg/deviceagent/state_cleanup.go diff --git a/.github/workflows/make.yaml b/.github/workflows/make.yaml index aca0fc35d..f3b0ead1f 100644 --- a/.github/workflows/make.yaml +++ b/.github/workflows/make.yaml @@ -143,7 +143,7 @@ jobs: if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi go build -ldflags "-s -w -X 'main.version=snapshot'" \ - -gcflags="-e" -o "dist/probo-agent${EXT}" ./cmd/probo-agent/main.go + -gcflags="-e" -o "dist/probo-agent${EXT}" ./cmd/probo-agent # ── Snapshot: build Docker images (matrix by architecture) ───────── build-snapshot-docker: @@ -281,12 +281,18 @@ jobs: - uses: "./.github/actions/setup" with: node: "false" + - name: "Lint install.sh" + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + sh -n cmd/probo-agent/installer/install.sh + shellcheck cmd/probo-agent/installer/install.sh - name: "Build probo-agent" env: CGO_ENABLED: "0" run: | go build -ldflags "-s -w -X 'main.version=snapshot'" \ - -gcflags="-e" -o bin/probo-agent ./cmd/probo-agent/main.go + -gcflags="-e" -o bin/probo-agent ./cmd/probo-agent lint-go: name: "lint-go" diff --git a/.github/workflows/release-probo-agent.yaml b/.github/workflows/release-probo-agent.yaml index 1c7703c4b..0493935c3 100644 --- a/.github/workflows/release-probo-agent.yaml +++ b/.github/workflows/release-probo-agent.yaml @@ -45,7 +45,7 @@ jobs: if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi go build -ldflags "-s -w -X 'main.version=${VERSION}'" \ - -gcflags="-e" -o "dist/probo-agent${EXT}" ./cmd/probo-agent/main.go + -gcflags="-e" -o "dist/probo-agent${EXT}" ./cmd/probo-agent - name: "Create archive" env: GOOS: "${{ matrix.goos }}" @@ -106,6 +106,33 @@ jobs: cd archives sha256sum *.tar.gz *.zip > checksums.txt cosign sign-blob --bundle="checksums.txt.bundle" checksums.txt --yes + - name: "Inject release checksums into install.sh" + run: | + TAG="${GITHUB_REF_NAME}" + cp cmd/probo-agent/installer/install.sh archives/install.sh + sed -i "s|^RELEASE_TAG=\"__PROBO_AGENT_RELEASE_TAG__\"|RELEASE_TAG=\"${TAG}\"|" archives/install.sh + grep 'probo-agent_.*\.tar\.gz' archives/checksums.txt > archives/probo-agent-checksums.txt + awk ' + /# __PROBO_AGENT_CHECKSUMS_BEGIN__/ { + print + while ((getline line < "archives/probo-agent-checksums.txt") > 0) print line + skip = 1 + next + } + /# __PROBO_AGENT_CHECKSUMS_END__/ { + skip = 0 + print + next + } + !skip { print } + END { + if (skip) { + print "install.sh injection failed: missing # __PROBO_AGENT_CHECKSUMS_END__ marker" > "/dev/stderr" + exit 1 + } + } + ' archives/install.sh > archives/install.sh.tmp + mv archives/install.sh.tmp archives/install.sh - name: "Generate SBOM" uses: "anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610" # v0.24.0 with: diff --git a/GNUmakefile b/GNUmakefile index aea65db34..7b03e8526 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,4 +1,5 @@ -MAKEFLAGS := --jobs=$(shell nproc) +NPROC ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1) +MAKEFLAGS := --jobs=$(NPROC) CAT ?= cat CP ?= cp @@ -39,7 +40,7 @@ GCFLAGS= -gcflags="-e" CGO_ENABLED?= 0 GOOS?= -GO_BASE= CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) go +GO_BASE= CGO_ENABLED=$(CGO_ENABLED) $(if $(GOOS),GOOS=$(GOOS)) go GO_BUILD= $(GO_BASE) build $(GCFLAGS) GO_GENERATE= $(GO_BASE) generate GO_TEST= $(GO_BASE) tool gotestsum -- $(TEST_FLAGS) @@ -88,7 +89,16 @@ PROBOCTL_BIN= bin/proboctl PROBOCTL_SRC= cmd/proboctl/main.go PROBO_AGENT_BIN= bin/probo-agent -PROBO_AGENT_SRC= cmd/probo-agent/main.go +PROBO_AGENT_SRC= ./cmd/probo-agent +# Menu bar / tray enrollment is macOS and Windows only; those hosts need CGO. +PROBO_AGENT_TARGET_OS= $(if $(GOOS),$(GOOS),$(shell $(GO) env GOOS)) +PROBO_AGENT_CGO= 0 +ifeq ($(PROBO_AGENT_TARGET_OS),darwin) +PROBO_AGENT_CGO= 1 +endif +ifeq ($(PROBO_AGENT_TARGET_OS),windows) +PROBO_AGENT_CGO= 1 +endif ifdef WITH_APPS GENERATED += relay @@ -291,6 +301,7 @@ bin/proboctl: $(GO_BUILD) $(PROBOCTL_LDFLAGS) -o $(PROBOCTL_BIN) $(PROBOCTL_SRC) .PHONY: $(PROBO_AGENT_BIN) +$(PROBO_AGENT_BIN): CGO_ENABLED=$(PROBO_AGENT_CGO) $(PROBO_AGENT_BIN): $(GO_BUILD) $(PROBO_AGENT_LDFLAGS) -o $(PROBO_AGENT_BIN) $(PROBO_AGENT_SRC) diff --git a/cmd/probo-agent/GNUmakefile b/cmd/probo-agent/GNUmakefile new file mode 100644 index 000000000..04c886956 --- /dev/null +++ b/cmd/probo-agent/GNUmakefile @@ -0,0 +1,74 @@ +CP ?= cp +MKDIR ?= mkdir -p +SUDO ?= sudo + +STATE_DIR= $(HOME)/.local/share/probo-agent-dev +CACHE_ROOT= $(HOME)/.cache/probo-agent-dev +DEV_TAG= probo-agent/dev +BINARY= /usr/local/bin/probo-agent +RELEASE_DIR= $(CACHE_ROOT)/release/$(DEV_TAG) +INSTALL_SCRIPT= installer/install.sh +REPO_ROOT= ../.. +PROBO_AGENT_BIN= $(REPO_ROOT)/bin/probo-agent + +UNAME_S:= $(shell uname -s) +UNAME_M:= $(shell uname -m) + +ifeq ($(UNAME_S),Darwin) +OS_LABEL= Darwin +else ifeq ($(UNAME_S),Linux) +OS_LABEL= Linux +else ifeq ($(UNAME_S),FreeBSD) +OS_LABEL= Freebsd +else +OS_LABEL= +endif + +ifeq ($(UNAME_M),x86_64) +ARCH_LABEL= x86_64 +else ifeq ($(UNAME_M),arm64) +ARCH_LABEL= arm64 +else ifeq ($(UNAME_M),aarch64) +ARCH_LABEL= arm64 +else +ARCH_LABEL= +endif + +AGENT_DIR= probo-agent_$(OS_LABEL)_$(ARCH_LABEL) +ARCHIVE_NAME= $(AGENT_DIR).tar.gz +ARCHIVE_PATH= $(RELEASE_DIR)/$(ARCHIVE_NAME) +STAGING_DIR= $(CACHE_ROOT)/staging/$(AGENT_DIR) +BUILD_BINARY= $(STAGING_DIR)/probo-agent + +INSTALL_ARGS?= --skip-service --dir "$(STATE_DIR)" +INSTALL_ENV= PROBO_AGENT_RELEASE_TAG="$(DEV_TAG)" \ + PROBO_AGENT_RELEASE_BASE="file://$(abspath $(RELEASE_DIR))" \ + PROBO_AGENT_SKIP_CHECKSUM_VERIFY=true \ + PROBO_AGENT_STATE_DIR="$(STATE_DIR)" \ + PROBO_SERVER_URL="$(PROBO_SERVER_URL)" \ + PROBO_ENROLLMENT_TOKEN="$(PROBO_ENROLLMENT_TOKEN)" + +.PHONY: all install run clean +all: install + +install: $(ARCHIVE_PATH) + $(SUDO) $(INSTALL_ENV) sh "$(INSTALL_SCRIPT)" $(INSTALL_ARGS) + +run: + $(SUDO) "$(BINARY)" run --dir "$(STATE_DIR)" + +clean: + rm -rf "$(STATE_DIR)" "$(CACHE_ROOT)" "$(BINARY)" + +$(ARCHIVE_PATH): $(BUILD_BINARY) + $(MKDIR) "$(RELEASE_DIR)" + tar -czf "$(ARCHIVE_PATH)" -C "$(CACHE_ROOT)/staging" "$(AGENT_DIR)" + +$(PROBO_AGENT_BIN): + $(MAKE) -C "$(REPO_ROOT)" bin/probo-agent + +$(BUILD_BINARY): $(PROBO_AGENT_BIN) + $(MKDIR) "$(STAGING_DIR)" + $(CP) "$(PROBO_AGENT_BIN)" "$(BUILD_BINARY)" + $(CP) "$(REPO_ROOT)/README.md" "$(REPO_ROOT)/LICENSE" "$(STAGING_DIR)/" + @if [ -f CHANGELOG.md ]; then $(CP) CHANGELOG.md "$(STAGING_DIR)/"; fi diff --git a/cmd/probo-agent/desktop.go b/cmd/probo-agent/desktop.go new file mode 100644 index 000000000..6b6f9297f --- /dev/null +++ b/cmd/probo-agent/desktop.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin || windows + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/deviceagent" + "go.probo.inc/probo/pkg/deviceagent/tray" +) + +func registerPlatformCommands(root *cobra.Command) { + root.AddCommand(newTrayCmd()) +} + +func registerTrayAutoStart(exePath string, runDir string) error { + if err := tray.RegisterAutoStart(exePath, runDir); err != nil { + return fmt.Errorf("cannot register tray auto-start: %w", err) + } + + return nil +} + +func newTrayCmd() *cobra.Command { + var runDir string + + cmd := &cobra.Command{ + Use: "tray", + Short: "Run the menu bar / system tray enrollment helper", + RunE: func(cmd *cobra.Command, args []string) error { + if runDir == "" { + runDir = deviceagent.DefaultEnrollmentRunDir() + } + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot resolve current executable path: %w", err) + } + + return tray.Run( + tray.Options{ + RunDir: runDir, + ExePath: exePath, + ServerURL: deviceagent.DefaultServerURL, + Version: version, + }, + ) + }, + } + + cmd.Flags().StringVar( + &runDir, + "run-dir", + deviceagent.DefaultEnrollmentRunDir(), + "directory containing the public enrollment marker", + ) + + return cmd +} diff --git a/cmd/probo-agent/desktop_stub.go b/cmd/probo-agent/desktop_stub.go new file mode 100644 index 000000000..bcae52e69 --- /dev/null +++ b/cmd/probo-agent/desktop_stub.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !darwin && !windows + +package main + +import "github.com/spf13/cobra" + +func registerPlatformCommands(_ *cobra.Command) {} + +func registerTrayAutoStart(_ string, _ string) error { + return nil +} diff --git a/cmd/probo-agent/installer/install.sh b/cmd/probo-agent/installer/install.sh new file mode 100755 index 000000000..44ade86f6 --- /dev/null +++ b/cmd/probo-agent/installer/install.sh @@ -0,0 +1,365 @@ +#!/bin/sh +# +# probo-agent installer for Darwin, Linux, and FreeBSD. +# +# Downloads the matching GitHub Release binary, verifies its sha256 +# checksum (embedded in this script at release time), installs to +# /usr/local/bin, then enrolls the device. +# +# Usage: +# +# # Interactive — curl install.sh from the target probo-agent/v* release +# curl -fsSL "https://github.com/getprobo/probo/releases/download/probo-agent/vX.Y.Z/install.sh" | sudo sh +# +# # Unattended / MDM +# curl -fsSL "…/install.sh" | sudo \ +# PROBO_SERVER_URL=https://us.probo.com \ +# PROBO_ENROLLMENT_TOKEN='…' sh +# +# # Mirror the release assets (must match the embedded release tag) +# PROBO_AGENT_RELEASE_BASE="https://release-base/probo-agent/vX.Y.Z" \ +# curl -fsSL "…/install.sh" | sudo sh +# +# # Explicit flags +# curl -fsSL "…/install.sh" | sudo sh -s -- \ +# --server https://us.probo.com \ +# --enrollment-token '…' +# +# Environment variables: +# PROBO_AGENT_RELEASE_BASE Release download base URL (default: embedded tag) +# PROBO_AGENT_RELEASE_TAG Override embedded release tag (local dev) +# PROBO_AGENT_SKIP_CHECKSUM_VERIFY Set to true to skip SHA-256 verification (local dev) +# PROBO_AGENT_STATE_DIR Agent state directory passed as --dir (default: /var/lib/probo-agent) +# PROBO_SERVER_URL Probo server base URL +# PROBO_ENROLLMENT_TOKEN One-shot enrollment token +# PROBO_NO_AUTO_UPDATE Set to true to pass --no-auto-update +# +# Never pass the enrollment token in the curl URL. + +set -eu +# pipefail is a bash/ksh extension; enable when available. +# shellcheck disable=SC3040 +(set -o pipefail 2>/dev/null) && set -o pipefail + +BINARY_PATH="/usr/local/bin/probo-agent" +GITHUB_RELEASES_URL="https://github.com/getprobo/probo/releases/download" + +# Injected at release time by .github/workflows/release-probo-agent.yaml +RELEASE_TAG="__PROBO_AGENT_RELEASE_TAG__" +if [ -n "${PROBO_AGENT_RELEASE_TAG:-}" ]; then + RELEASE_TAG="$PROBO_AGENT_RELEASE_TAG" +fi + +RELEASE_BASE="${PROBO_AGENT_RELEASE_BASE:-}" +SERVER_URL="${PROBO_SERVER_URL:-}" +ENROLLMENT_TOKEN="${PROBO_ENROLLMENT_TOKEN:-}" +STATE_DIR="${PROBO_AGENT_STATE_DIR:-}" +NO_AUTO_UPDATE="${PROBO_NO_AUTO_UPDATE:-}" +SKIP_SERVICE=false + +die() { + echo "error: $*" >&2 + exit 1 +} + +can_prompt() { + [ -t 0 ] && return 0 + [ -r /dev/tty ] && [ -w /dev/tty ] +} + +read_user() { + if [ -t 0 ]; then + IFS= read -r "$1" + else + IFS= read -r "$1" < /dev/tty + fi +} + +usage() { + cat <<'EOF' +probo-agent installer for Darwin, Linux, and FreeBSD. + +Usage: + curl -fsSL "…/install.sh" | sudo sh + curl -fsSL "…/install.sh" | sudo sh -s -- --server URL --enrollment-token TOKEN + +Environment variables: + PROBO_AGENT_RELEASE_BASE Release download base URL (default: embedded tag) + PROBO_AGENT_RELEASE_TAG Override embedded release tag (local dev) + PROBO_AGENT_SKIP_CHECKSUM_VERIFY Set to true to skip SHA-256 verification (local dev) + PROBO_AGENT_STATE_DIR Agent state directory (--dir; default /var/lib/probo-agent) + PROBO_SERVER_URL Probo server base URL + PROBO_ENROLLMENT_TOKEN One-shot enrollment token + PROBO_NO_AUTO_UPDATE Set to true to disable auto-update +EOF +} + +embedded_checksums() { + cat <<'EOF' +# __PROBO_AGENT_CHECKSUMS_BEGIN__ +# __PROBO_AGENT_CHECKSUMS_END__ +EOF +} + +resolve_embedded_release() { + if [ "$RELEASE_TAG" = "__PROBO_AGENT_RELEASE_TAG__" ]; then + die "this install.sh was not published by a probo-agent release; curl install.sh from the target release" + fi + + if [ -n "$RELEASE_BASE" ]; then + RELEASE_BASE="${RELEASE_BASE%/}" + case "$RELEASE_BASE" in + */"$RELEASE_TAG") ;; + *) die "PROBO_AGENT_RELEASE_BASE must end with release tag ${RELEASE_TAG}" ;; + esac + return 0 + fi + + RELEASE_BASE="${GITHUB_RELEASES_URL}/${RELEASE_TAG}" + printf 'Using release %s\n' "$RELEASE_TAG" +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + die "required command not found: $1" + fi +} + +detect_platform() { + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Darwin) os_label="Darwin" ;; + Linux) os_label="Linux" ;; + FreeBSD) os_label="Freebsd" ;; + *) die "unsupported operating system: $os (Darwin, Linux, and FreeBSD only)" ;; + esac + + case "$arch" in + x86_64 | amd64) arch_label="x86_64" ;; + arm64 | aarch64) arch_label="arm64" ;; + *) die "unsupported CPU architecture: $arch" ;; + esac + + archive_dir="probo-agent_${os_label}_${arch_label}" + archive_name="${archive_dir}.tar.gz" +} + +sha256_file() { + file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v sha256 >/dev/null 2>&1; then + sha256 -q "$file" + else + die "no sha256 tool found (need sha256sum, shasum, or sha256)" + fi +} + +verify_embedded_checksum() { + case "${PROBO_AGENT_SKIP_CHECKSUM_VERIFY:-}" in + 1 | true | TRUE | yes | YES) return 0 ;; + esac + + archive_file="$1" + archive_basename="$(basename "$archive_file")" + + expected="$( + embedded_checksums | awk -v name="$archive_basename" ' + $0 ~ /^#/ { next } + $2 == name { print $1; exit } + ' + )" + if [ -z "$expected" ]; then + die "archive ${archive_basename} not found in embedded release checksums" + fi + + actual="$(sha256_file "$archive_file")" + if [ "$expected" != "$actual" ]; then + die "checksum mismatch for ${archive_file}" + fi +} + +read_secret() { + prompt_text="$1" + printf '%s' "$prompt_text" + if command -v stty >/dev/null 2>&1; then + if [ -t 0 ]; then + old_stty="$(stty -g 2>/dev/null || true)" + stty -echo 2>/dev/null || true + IFS= read -r REPLY || REPLY="" + printf '\n' + if [ -n "${old_stty:-}" ]; then + stty "$old_stty" 2>/dev/null || stty echo 2>/dev/null || true + fi + else + old_stty="$(stty -g < /dev/tty 2>/dev/null || true)" + stty -echo < /dev/tty 2>/dev/null || true + IFS= read -r REPLY < /dev/tty || REPLY="" + printf '\n' >/dev/tty + if [ -n "${old_stty:-}" ]; then + stty "$old_stty" < /dev/tty 2>/dev/null || stty echo < /dev/tty 2>/dev/null || true + fi + fi + else + read_user REPLY + fi + ENROLLMENT_TOKEN="$REPLY" +} + +prompt_server_url() { + if [ -n "$SERVER_URL" ]; then + return 0 + fi + if ! can_prompt; then + die "PROBO_SERVER_URL is required in non-interactive mode" + fi + + printf '\nProbo server URL:\n' + printf ' 1) https://us.probo.com (United States)\n' + printf ' 2) https://eu.probo.com (European Union)\n' + printf ' 3) Enter a custom URL\n' + printf 'Choice [1]: ' + read_user choice + + case "${choice:-1}" in + 1 | "") SERVER_URL="https://us.probo.com" ;; + 2) SERVER_URL="https://eu.probo.com" ;; + 3) + printf 'Server URL: ' + read_user SERVER_URL + ;; + *) SERVER_URL="$choice" ;; + esac + + if [ -z "$SERVER_URL" ]; then + die "server URL is required" + fi +} + +prompt_enrollment_token() { + if [ -n "$ENROLLMENT_TOKEN" ]; then + return 0 + fi + if ! can_prompt; then + die "PROBO_ENROLLMENT_TOKEN is required in non-interactive mode" + fi + + read_secret "Enrollment token: " + if [ -z "$ENROLLMENT_TOKEN" ]; then + die "enrollment token is required" + fi +} + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --server) + [ $# -ge 2 ] || die "--server requires a value" + SERVER_URL="$2" + shift 2 + ;; + --enrollment-token) + [ $# -ge 2 ] || die "--enrollment-token requires a value" + ENROLLMENT_TOKEN="$2" + shift 2 + ;; + --no-auto-update) + NO_AUTO_UPDATE=true + shift + ;; + --skip-service) + SKIP_SERVICE=true + shift + ;; + --dir) + [ $# -ge 2 ] || die "--dir requires a value" + STATE_DIR="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1 (try --help)" + ;; + esac + done +} + +run_agent_install() { + set -- --server "$SERVER_URL" --enrollment-token "$ENROLLMENT_TOKEN" + if [ -n "$STATE_DIR" ]; then + set -- "$@" --dir "$STATE_DIR" + fi + case "$NO_AUTO_UPDATE" in + 1 | true | TRUE | yes | YES) set -- "$@" --no-auto-update ;; + esac + case "$SKIP_SERVICE" in + 1 | true | TRUE | yes | YES) set -- "$@" --skip-service ;; + esac + "$BINARY_PATH" install "$@" +} + +main() { + parse_args "$@" + + if [ "$(id -u)" -ne 0 ]; then + die "this installer must run as root; re-run with: curl -fsSL \"…/install.sh\" | sudo sh" + fi + + require_cmd curl + require_cmd tar + require_cmd install + + detect_platform + resolve_embedded_release + + workdir="$(mktemp -d "${TMPDIR:-/tmp}/probo-agent-install.XXXXXX")" + trap 'rm -rf "$workdir"' EXIT INT HUP TERM + + printf 'Downloading probo-agent %s …\n' "$archive_name" + + curl -fsSL "${RELEASE_BASE}/${archive_name}" -o "${workdir}/${archive_name}" + + verify_embedded_checksum "${workdir}/${archive_name}" + + tar -xzf "${workdir}/${archive_name}" -C "$workdir" + if [ ! -f "${workdir}/${archive_dir}/probo-agent" ]; then + die "archive did not contain probo-agent binary" + fi + + install -m 0755 "${workdir}/${archive_dir}/probo-agent" "$BINARY_PATH" + printf 'Installed %s\n' "$BINARY_PATH" + + prompt_server_url + prompt_enrollment_token + + printf 'Enrolling device …\n' + if run_agent_install; then + enroll_ok=true + else + enroll_ok=false + fi + + if [ "$enroll_ok" = true ]; then + case "$SKIP_SERVICE" in + 1 | true | TRUE | yes | YES) + printf 'Device enrolled (service installation skipped).\n' + ;; + *) + printf 'Device enrolled and service installed.\n' + ;; + esac + else + printf 'warning: probo-agent install failed; binary is at %s\n' "$BINARY_PATH" >&2 + printf 'Re-run: %s install --server … --enrollment-token …\n' "$BINARY_PATH" >&2 + exit 1 + fi +} + +main "$@" diff --git a/cmd/probo-agent/installer/macos/Resources/conclusion.html b/cmd/probo-agent/installer/macos/Resources/conclusion.html index ca783f068..d6a9cc03c 100644 --- a/cmd/probo-agent/installer/macos/Resources/conclusion.html +++ b/cmd/probo-agent/installer/macos/Resources/conclusion.html @@ -25,26 +25,37 @@ white-space: pre-wrap; word-break: break-all; } + @media (prefers-color-scheme: dark) { + body { color: #f5f5f7; } + code, pre { + background: #3a3a3c; + color: #f5f5f7; + } + }

Installation complete

- The probo-agent binary is installed and the launchd - unit is loaded. If the installer found a pre-staged - configuration file at /tmp/probo-agent.conf, the - device is already enrolled and the agent is running. + The probo-agent binary is installed. The Probo icon should + appear in the menu bar (or at your next login). To enroll, open the menu + and choose Enroll via…, then pick + United States, European Union, or + Self hosted…. Finish enrollment in your browser; when + it succeeds, the menu shows Connected and the agent + service starts automatically.

-

Enroll this device manually

- If you installed without a pre-staged configuration, finish the - setup from a Terminal: + If MDM already enrolled the device during installation, the menu bar + helper should already show Connected.

+ +

Administrators can also finish setup from a Terminal:

sudo probo-agent install \
-    --server https://app.getprobo.com \
-    --enrollment-token <TOKEN>
+ --server https://your-probo-host.example.com \ + --enrollment-token <ENROLLMENT_TOKEN>

Inspect the agent

sudo probo-agent status
diff --git a/cmd/probo-agent/installer/macos/Resources/welcome.html b/cmd/probo-agent/installer/macos/Resources/welcome.html
index c0448917b..3d65c5ab6 100644
--- a/cmd/probo-agent/installer/macos/Resources/welcome.html
+++ b/cmd/probo-agent/installer/macos/Resources/welcome.html
@@ -22,41 +22,57 @@
         pre { padding: 8px 10px; overflow-x: auto; }
         ul { margin: 4px 0 8px 18px; padding: 0; }
         li { margin: 2px 0; }
+        @media (prefers-color-scheme: dark) {
+            body { color: #f5f5f7; }
+            code, pre {
+                background: #3a3a3c;
+                color: #f5f5f7;
+            }
+        }
     
 
 
     

Welcome to the Probo Device Posture Agent

- This installer adds probo-agent to your Mac and starts it - as a system service. The agent reports device posture — disk - encryption, screen lock, firewall, OS version, and similar - signals — back to your Probo workspace over HTTPS. + This installer adds probo-agent, the menu bar helper, and + the probo:// enrollment app to your Mac. After enrollment, + the agent reports device posture — disk encryption, screen lock, + firewall, OS version, and similar signals — to your Probo + workspace over HTTPS.

What the installer does

  • Installs the probo-agent binary to /usr/local/bin/probo-agent.
  • -
  • Registers the launchd unit - com.probo.agent in - /Library/LaunchDaemons.
  • +
  • Installs Probo Agent.app to + /Applications so browser enrollment deep links + (probo://) work.
  • +
  • Registers the menu bar helper LaunchAgent + com.probo.agent.tray in + /Library/LaunchAgents.
  • Creates the persistent state directory /var/lib/probo-agent (root-owned, mode 0700).
  • -
  • Enrolls the device automatically when an admin has pre-staged - /tmp/probo-agent.conf (typically via an MDM).
  • +
  • Enrolls the device and starts the + com.probo.agent LaunchDaemon when an admin has + pre-staged /tmp/probo-agent.conf (typically via + MDM). Otherwise, finish enrollment from the menu bar icon.

What you will need

  • Administrator privileges on this Mac.
  • -
  • The Probo server URL (e.g. - https://app.getprobo.com).
  • +
  • The Probo server URL for your deployment (the URL of your Probo + console, e.g. https://your-probo-host.example.com). + Hosted Probo workspaces use the URL shown in your browser when you + sign in.
  • A device enrollment token issued by a workspace administrator.

Click Continue to review the license, then - Install to proceed. + Install to proceed. After installation, enroll from + the menu bar unless MDM already staged a configuration file.

diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh index 8ca8104ce..966d95f16 100755 --- a/cmd/probo-agent/installer/macos/build.sh +++ b/cmd/probo-agent/installer/macos/build.sh @@ -16,7 +16,9 @@ # script; consumers can chain `productsign` and `xcrun notarytool` # afterwards. # -# Must run on macOS: pkgbuild and productbuild are Apple-only tools. +# Must run on macOS: pkgbuild, productbuild, and swift build are +# Apple-only tools. The build also compiles Probo Agent.app (the +# probo:// URL handler) from enroll-ui/. set -euo pipefail @@ -67,6 +69,10 @@ if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null echo "error: pkgbuild and productbuild are required (run on macOS)" >&2 exit 1 fi +if ! command -v swift >/dev/null 2>&1; then + echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2 + exit 1 +fi STAGE="$(mktemp -d -t probo-agent-pkg)" trap 'rm -rf "${STAGE}"' EXIT @@ -78,6 +84,12 @@ mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}" install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent" +mkdir -p "${PAYLOAD}/Applications" +"${SCRIPT_DIR}/enroll-ui/build-app.sh" \ + --arch "${ARCH}" \ + --version "${VERSION}" \ + --output "${PAYLOAD}/Applications" + install -m 0755 "${SCRIPT_DIR}/scripts/postinstall" "${SCRIPTS}/postinstall" cp "${SCRIPT_DIR}/Resources/welcome.html" "${RESOURCES}/welcome.html" diff --git a/cmd/probo-agent/installer/macos/enroll-ui/.gitignore b/cmd/probo-agent/installer/macos/enroll-ui/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl b/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl new file mode 100644 index 000000000..1bc39ab5e --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl @@ -0,0 +1,45 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + probo-agent-url-handler + CFBundleIdentifier + com.getprobo.agent.url-handler + CFBundleName + Probo Agent + CFBundleDisplayName + Probo Agent + CFBundlePackageType + APPL + CFBundleShortVersionString + @@VERSION@@ + CFBundleVersion + @@VERSION@@ + LSMinimumSystemVersion + 11.0 + LSUIElement + + CFBundleURLTypes + + + CFBundleURLName + Probo Enrollment + CFBundleURLSchemes + + probo + + + + + diff --git a/cmd/probo-agent/installer/macos/enroll-ui/Package.swift b/cmd/probo-agent/installer/macos/enroll-ui/Package.swift new file mode 100644 index 000000000..29bb9e7ad --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "probo-agent-url-handler", + platforms: [ + .macOS(.v11), + ], + targets: [ + .executableTarget( + name: "probo-agent-url-handler", + path: "URLHandlerSources" + ), + ] +) diff --git a/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift b/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift new file mode 100644 index 000000000..08feffbe3 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/URLHandlerSources/main.swift @@ -0,0 +1,201 @@ +import AppKit +import Darwin +import Foundation + +// Fixed install location written by the macOS PKG postinstall script +// (cmd/probo-agent/installer/macos/scripts/postinstall, BINARY). +private let agentExecutablePath = "/usr/local/bin/probo-agent" + +private enum EnrollmentCallbackState: String, Codable { + case success + case failure +} + +private struct EnrollmentCallbackPayload: Codable { + let state: EnrollmentCallbackState + let message: String? +} + +private enum EnrollmentCallbackStore { + static var statusFileURL: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("probo-agent-enrollment-status.json") + } + + static var lockFileURL: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("probo-agent-enrollment-ui.lock") + } + + static func isWizardRunning() -> Bool { + guard + let data = try? Data(contentsOf: lockFileURL), + let pidText = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int32(pidText), + pid > 0 + else { + return false + } + + return kill(pid, 0) == 0 + } + + static func writeStatus( + state: EnrollmentCallbackState, + message: String? + ) { + let payload = EnrollmentCallbackPayload(state: state, message: message) + guard let data = try? JSONEncoder().encode(payload) else { + return + } + + try? data.write(to: statusFileURL, options: [.atomic]) + } +} + +private final class URLHandlerApp: NSObject, NSApplicationDelegate { + private var didReceiveURL = false + + override init() { + super.init() + + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL) + ) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in + if !self.didReceiveURL { + NSApp.terminate(nil) + } + } + } + + @objc private func handleGetURLEvent( + _ event: NSAppleEventDescriptor, + withReplyEvent replyEvent: NSAppleEventDescriptor + ) { + guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { + reportFailure("Enrollment link is missing.") + return + } + + guard !didReceiveURL else { return } + + didReceiveURL = true + runEnrollment(for: rawURL) + } + + private func runEnrollment(for rawURL: String) { + let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning() + + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: agentExecutablePath) + process.arguments = ["enroll-url", rawURL] + + let output = Pipe() + process.standardOutput = output + process.standardError = output + + var outputData = Data() + let readHandle = output.fileHandleForReading + let readDone = DispatchSemaphore(value: 0) + + readHandle.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + readDone.signal() + return + } + outputData.append(chunk) + } + + defer { readHandle.readabilityHandler = nil } + + do { + try process.run() + } catch { + readDone.signal() + DispatchQueue.main.async { + self.reportFailure( + 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()) { + if shouldNotifyWizard { + EnrollmentCallbackStore.writeStatus(state: .failure, message: message) + NSApp.terminate(nil) + return + } + + showError(message) + } + + private func sanitizedFailureMessage(_ raw: String?) -> String { + guard let raw else { + return "Enrollment failed. Please try again." + } + + let normalized = raw.lowercased() + if normalized.contains("already enrolled") { + return "This device is already enrolled." + } + if normalized.contains("key") && normalized.contains("missing") { + return "Device API key is missing or invalid." + } + + return "Enrollment failed. Please try again." + } + + private func showError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Enrollment failed" + alert.informativeText = message + alert.alertStyle = .warning + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + NSApp.terminate(nil) + } +} + +let app = NSApplication.shared +private let delegate = URLHandlerApp() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh b/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh new file mode 100755 index 000000000..4886d68a8 --- /dev/null +++ b/cmd/probo-agent/installer/macos/enroll-ui/build-app.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# +# Build Probo Agent.app — a headless macOS app bundle that registers +# the probo:// URL scheme and forwards enrollment links to probo-agent. +# +# Required arguments: +# --arch amd64 or arm64 +# --version Agent version, e.g. 0.1.0 +# --output Parent directory; creates "Probo Agent.app" inside it +# +# Must run on macOS with the Swift toolchain (swift build). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ARCH="" +VERSION="" +OUTPUT="" +APP_NAME="Probo Agent.app" +EXECUTABLE_NAME="probo-agent-url-handler" + +usage() { + sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" +} + +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown flag: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "${ARCH}" ]; then + echo "error: --arch (amd64|arm64) is required" >&2 + exit 2 +fi +case "${ARCH}" in + amd64) SWIFT_ARCH="x86_64" ;; + arm64) SWIFT_ARCH="arm64" ;; + *) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;; +esac +if [ -z "${VERSION}" ]; then + echo "error: --version is required" >&2 + exit 2 +fi +if [ -z "${OUTPUT}" ]; then + echo "error: --output is required" >&2 + exit 2 +fi + +if ! command -v swift >/dev/null 2>&1; then + echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2 + exit 1 +fi + +BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)" +trap 'rm -rf "${BUILD_DIR}"' EXIT + +pushd "${SCRIPT_DIR}" >/dev/null +swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}" +BIN_DIR="$(swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}" --show-bin-path)" +BINARY="${BIN_DIR}/${EXECUTABLE_NAME}" +popd >/dev/null + +if [ ! -x "${BINARY}" ]; then + echo "error: release binary not found at ${BINARY}" >&2 + exit 1 +fi + +APP_ROOT="${OUTPUT}/${APP_NAME}" +CONTENTS="${APP_ROOT}/Contents" +MACOS="${CONTENTS}/MacOS" +PLIST="${CONTENTS}/Info.plist" + +rm -rf "${APP_ROOT}" +mkdir -p "${MACOS}" + +install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}" + +sed \ + -e "s|@@VERSION@@|${VERSION}|g" \ + "${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}" + +if ! plutil -lint "${PLIST}" >/dev/null; then + echo "error: rendered Info.plist failed plutil -lint" >&2 + exit 1 +fi +if ! grep -q 'probo' "${PLIST}"; then + echo "error: Info.plist is missing probo URL scheme" >&2 + exit 1 +fi + +echo "Built ${APP_ROOT}" diff --git a/cmd/probo-agent/installer/macos/scripts/postinstall b/cmd/probo-agent/installer/macos/scripts/postinstall index fc9565dc7..62de0c137 100755 --- a/cmd/probo-agent/installer/macos/scripts/postinstall +++ b/cmd/probo-agent/installer/macos/scripts/postinstall @@ -12,14 +12,17 @@ # # We intentionally do not abort the install if enrollment fails: # the binary is laid down regardless, and the operator can finish -# enrollment with `sudo probo-agent install ...` from Terminal. +# enrollment from the menu bar helper. set -u LOG_FILE="/var/log/probo-agent-install.log" BINARY="/usr/local/bin/probo-agent" STATE_DIR="/var/lib/probo-agent" +RUN_DIR="/var/run/probo-agent" CONF_FILE="/tmp/probo-agent.conf" +TRAY_LABEL="com.probo.agent.tray" +TRAY_PLIST_NAME="${TRAY_LABEL}.plist" # Mirror everything to the install log. We keep stdout/stderr open # too so failures still surface in macOS Installer.app's log pane. @@ -39,11 +42,123 @@ mkdir -p "${STATE_DIR}" chown root:wheel "${STATE_DIR}" chmod 0700 "${STATE_DIR}" +mkdir -p "${RUN_DIR}" +chown root:wheel "${RUN_DIR}" +chmod 0755 "${RUN_DIR}" + +register_tray_launchagent() { + local current_user user_uid agents_dir plist_path + + agents_dir="/Library/LaunchAgents" + plist_path="${agents_dir}/${TRAY_PLIST_NAME}" + + mkdir -p "${agents_dir}" + cat > "${plist_path}" < + + + + Label + ${TRAY_LABEL} + ProgramArguments + + ${BINARY} + tray + --run-dir + ${RUN_DIR} + + RunAtLoad + + KeepAlive + + + +EOF + + chmod 0644 "${plist_path}" + echo "Installed tray LaunchAgent at ${plist_path}." + + bootstrap_tray_for_user() { + local username="$1" + local user_uid + + if [ -z "${username}" ] || \ + [ "${username}" = "root" ] || \ + [ "${username}" = "loginwindow" ]; then + return 1 + fi + + user_uid="$(id -u "${username}" 2>/dev/null || true)" + if [ -z "${user_uid}" ]; then + echo "warning: cannot resolve uid for ${username}; skipping tray bootstrap." + return 1 + fi + + launchctl bootout "gui/${user_uid}/${TRAY_LABEL}" 2>/dev/null || true + if ! launchctl bootstrap "gui/${user_uid}" "${plist_path}"; then + echo "warning: could not start tray helper for ${username}; it will start at next GUI login." + return 1 + fi + + echo "Started tray LaunchAgent for ${username}." + return 0 + } + + started_any=false + seen_users=" " + + for username in $(users 2>/dev/null || true); do + case "${seen_users}" in + *" ${username} "*) continue ;; + esac + seen_users="${seen_users}${username} " + + if bootstrap_tray_for_user "${username}"; then + started_any=true + fi + done + + if [ "${started_any}" = false ]; then + current_user=$(stat -f "%Su" /dev/console 2>/dev/null || true) + if bootstrap_tray_for_user "${current_user}"; then + started_any=true + fi + fi + + if [ "${started_any}" = false ]; then + echo "No active GUI session found; tray helper will start at next GUI login." + fi +} + +register_enrollment_url_scheme() { + local app_path lsregister + + app_path="/Applications/Probo Agent.app" + if [ ! -d "${app_path}" ]; then + echo "warning: ${app_path} not found; cannot register probo:// URL scheme." + return 0 + fi + + lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + if [ ! -x "${lsregister}" ]; then + echo "warning: lsregister is unavailable; URL scheme registration skipped." + return 0 + fi + + if ! "${lsregister}" -f "${app_path}"; then + echo "warning: failed to register probo:// URL scheme." + return 0 + fi + + echo "Registered probo:// URL scheme." +} + # An admin (or MDM) may stage /tmp/probo-agent.conf to drive an # unattended enrollment. Recognized keys (shell-style): # -# PROBO_SERVER_URL=https://app.getprobo.com -# PROBO_ENROLLMENT_TOKEN= +# PROBO_SERVER_URL=https://your-probo-host.example.com +# PROBO_ENROLLMENT_TOKEN= # PROBO_NO_AUTO_UPDATE=true # # Parse KEY=VALUE lines without sourcing or eval so a crafted conf @@ -61,7 +176,7 @@ if [ -f "${CONF_FILE}" ]; then echo "Found ${CONF_FILE}, attempting unattended enrollment." CONF_SERVER="" - CONF_TOKEN="" + CONF_ENROLLMENT_TOKEN="" CONF_NOUPDATE="" while IFS= read -r line || [ -n "$line" ]; do line="${line%%#*}" @@ -74,7 +189,7 @@ if [ -f "${CONF_FILE}" ]; then CONF_SERVER="$(strip_conf_value "${line#PROBO_SERVER_URL=}")" ;; PROBO_ENROLLMENT_TOKEN=*) - CONF_TOKEN="$(strip_conf_value "${line#PROBO_ENROLLMENT_TOKEN=}")" + CONF_ENROLLMENT_TOKEN="$(strip_conf_value "${line#PROBO_ENROLLMENT_TOKEN=}")" ;; PROBO_NO_AUTO_UPDATE=*) CONF_NOUPDATE="$(strip_conf_value "${line#PROBO_NO_AUTO_UPDATE=}")" @@ -82,32 +197,38 @@ if [ -f "${CONF_FILE}" ]; then esac done < "${CONF_FILE}" - if [ -z "${CONF_SERVER}" ] || [ -z "${CONF_TOKEN}" ]; then + if [ -z "${CONF_SERVER}" ] || [ -z "${CONF_ENROLLMENT_TOKEN}" ]; then echo "warning: ${CONF_FILE} is missing PROBO_SERVER_URL or PROBO_ENROLLMENT_TOKEN; skipping enrollment." else - EXTRA_FLAGS=() + # Build argv from the first element so "${INSTALL_ARGS[@]}" + # is never empty — macOS /bin/bash 3.2 treats an unset empty + # array as unbound under `set -u`. + INSTALL_ARGS=( + install + --server "${CONF_SERVER}" + --enrollment-token "${CONF_ENROLLMENT_TOKEN}" + ) case "${CONF_NOUPDATE}" in - 1|true|TRUE|yes|YES) EXTRA_FLAGS+=("--no-auto-update") ;; + 1|true|TRUE|yes|YES) INSTALL_ARGS+=(--no-auto-update) ;; esac - if "${BINARY}" install \ - --server "${CONF_SERVER}" \ - --enrollment-token "${CONF_TOKEN}" \ - "${EXTRA_FLAGS[@]}"; then + if "${BINARY}" "${INSTALL_ARGS[@]}"; then echo "Device enrolled and service installed." else echo "warning: probo-agent install failed; the binary is in place and can be re-run by an admin." fi fi - # The token in the conf file is sensitive; clear it whatever + # The enrollment token in the conf file is sensitive; clear it # the outcome so a successful install does not leave secrets # in /tmp. rm -f "${CONF_FILE}" else - echo "No ${CONF_FILE} found; skipping automatic enrollment." - echo "Finish setup with: sudo ${BINARY} install --server --enrollment-token " + echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon." fi +register_tray_launchagent +register_enrollment_url_scheme + echo "=== postinstall done ===" exit 0 diff --git a/cmd/probo-agent/installer/macos/scripts/preinstall b/cmd/probo-agent/installer/macos/scripts/preinstall new file mode 100644 index 000000000..ecf39c3a7 --- /dev/null +++ b/cmd/probo-agent/installer/macos/scripts/preinstall @@ -0,0 +1,9 @@ +#!/bin/bash +# +# probo-agent macOS PKG preinstall script. +# +# Enrollment is handled by the menu bar helper after installation. +# MDM may still pre-stage /tmp/probo-agent.conf for unattended +# enrollment in postinstall. + +exit 0 diff --git a/cmd/probo-agent/installer/regions.json b/cmd/probo-agent/installer/regions.json new file mode 100644 index 000000000..5d28e0b65 --- /dev/null +++ b/cmd/probo-agent/installer/regions.json @@ -0,0 +1,27 @@ +{ + "regions": [ + { + "id": "us", + "title": "United States", + "subtitle": "us.probo.com", + "flag": "🇺🇸", + "server_url": "https://us.probo.com" + }, + { + "id": "eu", + "title": "European Union", + "subtitle": "eu.probo.com", + "flag": "🇪🇺", + "server_url": "https://eu.probo.com" + }, + { + "id": "self_hosted", + "title": "Self hosted", + "subtitle": "Your own Probo instance", + "flag": "🌐", + "server_url": null + } + ], + "employee_page_hint": "Open browser enrollment, choose your organization, then click the enroll button.", + "self_hosted_hostname_hint": "Enter your hostname above to continue enrollment in your browser." +} diff --git a/cmd/probo-agent/installer/regions_manifest_test.go b/cmd/probo-agent/installer/regions_manifest_test.go new file mode 100644 index 000000000..ee5bc6b2e --- /dev/null +++ b/cmd/probo-agent/installer/regions_manifest_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package installer_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/deviceagent" +) + +type regionsManifest struct { + Regions []regionEntry `json:"regions"` +} + +type regionEntry struct { + ID string `json:"id"` + ServerURL *string `json:"server_url"` +} + +func TestRegionsManifestMatchesGoConstants(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("regions.json") + require.NoError(t, err) + + var manifest regionsManifest + require.NoError(t, json.Unmarshal(data, &manifest)) + + urls := map[string]string{} + + for _, region := range manifest.Regions { + if region.ServerURL != nil { + urls[region.ID] = *region.ServerURL + } + } + + assert.Equal(t, deviceagent.USConsoleURL, urls["us"]) + assert.Equal(t, deviceagent.EUConsoleURL, urls["eu"]) + assert.NotContains(t, urls, "self_hosted") +} diff --git a/cmd/probo-agent/installer/windows/register-protocol.ps1 b/cmd/probo-agent/installer/windows/register-protocol.ps1 new file mode 100644 index 000000000..aad5cad79 --- /dev/null +++ b/cmd/probo-agent/installer/windows/register-protocol.ps1 @@ -0,0 +1,32 @@ +# Copyright (c) 2026 Probo Inc . +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +[CmdletBinding()] +param( + [string]$AgentPath = "$env:ProgramFiles\Probo\probo-agent.exe" +) + +if (-not (Test-Path -LiteralPath $AgentPath)) { + throw "probo-agent executable not found at $AgentPath" +} + +$protocolRoot = "HKCU:\Software\Classes\probo" +$commandRoot = Join-Path $protocolRoot "shell\open\command" + +New-Item -Path $commandRoot -Force | Out-Null +Set-ItemProperty -Path $protocolRoot -Name "(Default)" -Value "URL:Probo Enrollment Protocol" +Set-ItemProperty -Path $protocolRoot -Name "URL Protocol" -Value "" +Set-ItemProperty -Path $commandRoot -Name "(Default)" -Value "`"$AgentPath`" `"%1`"" + +Write-Host "Registered probo:// protocol for current user." diff --git a/cmd/probo-agent/main.go b/cmd/probo-agent/main.go index 616b6fe41..4aa18c148 100644 --- a/cmd/probo-agent/main.go +++ b/cmd/probo-agent/main.go @@ -28,14 +28,15 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "time" "github.com/spf13/cobra" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/deviceagent" + "go.probo.inc/probo/pkg/deviceagent/elevate" "go.probo.inc/probo/pkg/deviceagent/service" + "go.probo.inc/probo/pkg/deviceagent/tray" "go.probo.inc/probo/pkg/deviceagent/update" // Side-effect import: registers per-OS posture checks. @@ -58,6 +59,12 @@ func main() { update.CleanupAfterRestart(exe) } + if len(os.Args) == 2 { + if _, _, err := deviceagent.ParseEnrollURL(os.Args[1]); err == nil { + os.Args = []string{os.Args[0], "enroll-url", os.Args[1]} + } + } + if err := newRootCmd().Execute(); err != nil { if errors.Is(err, deviceagent.ErrRestartRequired) { os.Exit(restartExitCode) @@ -86,10 +93,52 @@ func newRootCmd() *cobra.Command { root.AddCommand(newStatusCmd()) root.AddCommand(newCollectCmd()) root.AddCommand(newUpdateCmd()) + root.AddCommand(newEnrollURLCmd()) + registerPlatformCommands(root) return root } +func newEnrollURLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "enroll-url [url]", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + serverURL, enrollmentToken, err := deviceagent.ParseEnrollURL(args[0]) + if err != nil { + return err + } + + dir := resolveDir(cmd) + + enrolled, err := deviceagent.IsEnrolled(deviceagent.EnrollmentRunDir(dir)) + if err != nil { + return fmt.Errorf("cannot check enrollment state: %w", err) + } + + if enrolled { + return errors.New("device is already enrolled") + } + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot resolve current executable path: %w", err) + } + + if err := elevate.RunElevatedInstall(exePath, serverURL, enrollmentToken, dir); err != nil { + return fmt.Errorf("cannot start elevated enrollment install: %w", err) + } + + fmt.Println("Enrollment started.") + + return nil + }, + } + + return cmd +} + // newUpdater returns an Updater scoped to the running binary, or nil // when self-update cannot be performed (unresolvable binary path). // @@ -142,14 +191,21 @@ func newInstallCmd() *cobra.Command { return errors.New("--server is required") } + var err error + + serverURL, err = deviceagent.NormalizeServerURL(serverURL) + if err != nil { + return fmt.Errorf("invalid --server: %w", err) + } + if enrollmentToken == "" { - if v := os.Getenv("PROBO_TOKEN"); v != "" { + if v := os.Getenv("PROBO_ENROLLMENT_TOKEN"); v != "" { enrollmentToken = v } } if enrollmentToken == "" { - return errors.New("--enrollment-token (or PROBO_TOKEN env var) is required") + return errors.New("--enrollment-token (or PROBO_ENROLLMENT_TOKEN env var) is required") } dir := resolveDir(cmd) @@ -157,14 +213,25 @@ func newInstallCmd() *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) defer cancel() - agent := deviceagent.New(dir, version, newAgentLogger()) + client := deviceagent.NewClient( + serverURL, + "", + fmt.Sprintf("probo-agent/%s", version), + ) - resp, err := agent.EnrollNewDevice(ctx, strings.TrimRight(serverURL, "/"), enrollmentToken) + apiKey, err := deviceagent.LoadOrExchangeAPIKey(ctx, dir, client, serverURL, enrollmentToken) if err != nil { - return fmt.Errorf("enrollment failed: %w", err) + return fmt.Errorf("cannot obtain device api key: %w", err) } - fmt.Printf("Enrolled device %s (heartbeat %ds, posture %ds)\n", + agent := deviceagent.New(dir, version, newAgentLogger()) + + resp, err := agent.ConfigureDevice(ctx, serverURL, apiKey) + if err != nil { + return fmt.Errorf("device configuration failed: %w", err) + } + + fmt.Printf("Configured device %s (heartbeat %ds, posture %ds)\n", resp.DeviceID, resp.HeartbeatSeconds, resp.PostureSeconds) if noAutoUpdate { @@ -196,12 +263,12 @@ func newInstallCmd() *cobra.Command { fmt.Println("Service installed and started.") - return nil + return registerTrayAutoStart(exePath, deviceagent.EnrollmentRunDir(dir)) }, } - cmd.Flags().StringVar(&serverURL, "server", "", "Probo server base URL (e.g. https://app.getprobo.com)") - cmd.Flags().StringVar(&enrollmentToken, "enrollment-token", "", "device enrollment token issued by an admin") + cmd.Flags().StringVar(&serverURL, "server", "", "Probo server base URL (e.g. https://your-probo-host.example.com)") + cmd.Flags().StringVar(&enrollmentToken, "enrollment-token", "", "one-shot enrollment token issued when the device was created") cmd.Flags().BoolVar(&skipService, "skip-service", false, "register the device but do not install the OS service") cmd.Flags().BoolVar(&noAutoUpdate, "no-auto-update", false, "disable automatic upgrades of the agent binary") @@ -240,7 +307,13 @@ func newUninstallCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "warning: service uninstall failed: %v\n", err) } - _ = os.Remove(deviceagent.ConfigPath(dir)) + if err := tray.UnregisterAutoStart(); err != nil { + fmt.Fprintf(os.Stderr, "warning: tray uninstall failed: %v\n", err) + } + + if err := deviceagent.RemoveLocalState(dir); err != nil { + return fmt.Errorf("cannot remove agent state: %w", err) + } fmt.Println("Uninstalled.") diff --git a/contrib/claude/release/probo-agent.md b/contrib/claude/release/probo-agent.md index 07278d46c..598233cb9 100644 --- a/contrib/claude/release/probo-agent.md +++ b/contrib/claude/release/probo-agent.md @@ -28,6 +28,10 @@ If empty or non-user-facing only, do not release this track. make bin/probo-agent ``` +On macOS and Windows hosts, `make` enables CGO automatically so the +menu bar / tray enrollment helper is included. Linux and FreeBSD builds +stay pure Go (no tray). + ## Notes CI builds binaries for 8 OS/arch targets (linux, darwin, and windows on @@ -36,6 +40,106 @@ Release with signed checksums, SBOM, and build attestations. The agent auto-update path downloads the matching archive plus `checksums.txt` and verifies the cosign bundle before installing. +The menu bar / tray enrollment flow is **macOS and Windows only**. +Linux and FreeBSD use `probo-agent install --server … +--enrollment-token …` from the shell, or the curl-to-sh installer +documented below. Windows release binaries are +cross-compiled from Linux with MinGW (CGO). macOS release binaries and +`.pkg` installers must be built on macOS with `CGO_ENABLED=1`. + macOS `.pkg` installers are built locally with -`cmd/probo-agent/installer/macos/build.sh` (requires macOS and a -pre-built binary). They are not part of the GitHub Release workflow yet. +`cmd/probo-agent/installer/macos/build.sh` (requires macOS, a +pre-built binary, and the Swift toolchain). The script also compiles +`Probo Agent.app` — the headless `probo://` URL handler installed to +`/Applications` — from `cmd/probo-agent/installer/macos/enroll-ui/`. +They are not part of the GitHub Release workflow yet. + +Windows enrollment is browser-driven: the console issues a +`probo://enroll?server=...&token=...` deep link handled by +`probo-agent enroll-url`. After install, register the protocol for the +current user with +`cmd/probo-agent/installer/windows/register-protocol.ps1` (per-user +`HKCU` handler pointing at `probo-agent.exe`). The system tray helper +(`probo-agent tray`, CGO build on local Windows hosts) shows enrollment +status; enrollment itself happens in the browser. + +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 +sync with `pkg/deviceagent/server_url.go`. + +## Install script + +`cmd/probo-agent/installer/install.sh` is published as `install.sh` on each +`probo-agent/v*` GitHub Release. It supports Darwin, Linux, and FreeBSD. +Each published `install.sh` pins one release: the release workflow injects +the tag and archive SHA-256 checksums into the script before upload. + +```shell +# Interactive — curl install.sh from the target release +curl -fsSL "https://github.com/getprobo/probo/releases/download/probo-agent/vX.Y.Z/install.sh" | sudo sh + +# Unattended / MDM +curl -fsSL "…/install.sh" | sudo \ + PROBO_SERVER_URL=https://us.probo.com \ + PROBO_ENROLLMENT_TOKEN='…' sh + +# Mirror release assets (PROBO_AGENT_RELEASE_BASE must end with the embedded tag) +PROBO_AGENT_RELEASE_BASE="https://mirror.example/probo-agent/vX.Y.Z" \ + curl -fsSL "…/install.sh" | sudo sh +``` + +The script downloads only the matching platform archive and verifies its +SHA-256 against checksums embedded in `install.sh` at release time. This +anchors trust to the script the user already obtained, rather than a +co-downloaded `checksums.txt`. Post-install upgrades still use cosign +bundle verification via the agent auto-update path. + +The script installs the binary to `/usr/local/bin/probo-agent`, then runs +`probo-agent install` to enroll and start the OS service. Agent state defaults +to `/var/lib/probo-agent` (override with `--dir` or `PROBO_AGENT_STATE_DIR`). + +Environment variables: + +| Variable | Purpose | +|----------|---------| +| `PROBO_AGENT_RELEASE_BASE` | Override release download base URL (must match embedded tag) | +| `PROBO_AGENT_RELEASE_TAG` | Override embedded release tag (local dev) | +| `PROBO_AGENT_SKIP_CHECKSUM_VERIFY` | Skip SHA-256 verification (local dev only) | +| `PROBO_AGENT_STATE_DIR` | Agent state directory (`--dir`; default `/var/lib/probo-agent`) | +| `PROBO_SERVER_URL` | Probo server base URL (skip interactive prompt) | +| `PROBO_ENROLLMENT_TOKEN` | One-shot enrollment token (skip interactive prompt) | +| `PROBO_NO_AUTO_UPDATE` | Set to `true` to pass `--no-auto-update` | + +Never pass the enrollment token in the curl URL. + +## Local dev install + +Build, package, and install a dev binary with `sudo`. Agent state is stored +under `~/.local/share/probo-agent-dev` (`--dir`); release archives are staged +under `~/.cache/probo-agent-dev` so the two trees do not overlap. + +```shell +make -C cmd/probo-agent install \ + PROBO_SERVER_URL=https://us.probo.com \ + PROBO_ENROLLMENT_TOKEN='…' +``` + +This does not compile Go in `cmd/probo-agent`; it reuses `bin/probo-agent` from +the root `GNUmakefile` (via `make bin/probo-agent`, invoked automatically when +needed), stages it into a local release archive, then runs `installer/install.sh` +against that `file://` archive with `PROBO_AGENT_RELEASE_TAG=probo-agent/dev`, +skips checksum verification, installs the binary to `/usr/local/bin/probo-agent`, +and passes `--skip-service` and `--dir ~/.local/share/probo-agent-dev` by default +(`INSTALL_ARGS` overrides). + +Run the agent in the foreground against the same dev state directory: + +```shell +make -C cmd/probo-agent run +``` + +Remove dev artifacts: + +```shell +make -C cmd/probo-agent clean +``` diff --git a/pkg/deviceagent/agent.go b/pkg/deviceagent/agent.go index 443afb239..b3015b00d 100644 --- a/pkg/deviceagent/agent.go +++ b/pkg/deviceagent/agent.go @@ -331,32 +331,20 @@ func (a *Agent) CollectOnce(ctx context.Context) []checks.Result { return results } -// Unenroll revokes server state best-effort and clears local credentials. +// Unenroll asks the server to revoke this device. Local wipe is left to +// RemoveLocalState (called by uninstall after service/tray teardown). +// Missing credentials or server failures are logged and ignored so +// uninstall can continue. func (a *Agent) Unenroll(ctx context.Context) error { if a.cfg == nil || a.client == nil { if err := a.LoadLocalState(); err != nil { - return err + a.Logger.WarnCtx(ctx, "cannot load local state for unenroll", log.Error(err)) + return nil } } if err := a.client.Unenroll(ctx); err != nil { - a.Logger.WarnCtx( - ctx, - "unenroll server-side revocation failed, continuing with local wipe", - log.Error(err), - ) - } - - if err := DeleteAPIKey(a.Dir); err != nil { - return err - } - - if err := clearPendingPostureBatches(a.Dir); err != nil { - return err - } - - if err := ClearEnrollmentMarker(EnrollmentRunDir(a.Dir)); err != nil { - return err + a.Logger.WarnCtx(ctx, "unenroll server-side revocation failed", log.Error(err)) } return nil diff --git a/pkg/deviceagent/state_cleanup.go b/pkg/deviceagent/state_cleanup.go new file mode 100644 index 000000000..55c2ac260 --- /dev/null +++ b/pkg/deviceagent/state_cleanup.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + // SigstoreCacheDirName is the TUF metadata cache under the agent + // state directory. + SigstoreCacheDirName = "sigstore-cache" + + // configFileTmpName is the atomic-write leftover from SaveConfig. + configFileTmpName = ConfigFileName + ".tmp" +) + +// RemoveLocalState deletes known agent local state for an install: allowlisted +// files under the state dir and EnrollmentRunDir, plus the sigstore-cache +// subdirectory. It never recursively deletes either directory itself. +// Missing paths are ignored. Filesystem roots and empty paths are rejected. +// +// Every path is attempted even if an earlier removal fails. +// +// enrolling.lock is intentionally retained: flock binds to the inode, +// so unlinking the path while LoadOrExchangeAPIKey holds the lock would +// let a concurrent caller create a new inode and bypass serialization. +func RemoveLocalState(dir string) error { + if dir == "" { + return errors.New("state directory is empty") + } + + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("cannot resolve state directory: %w", err) + } + + cleaned := filepath.Clean(abs) + if isFilesystemRoot(cleaned) { + return fmt.Errorf("refusing to remove filesystem root %q", cleaned) + } + + // Use the original dir (not Abs/Clean) so custom relative --dir values + // resolve the same sibling run tree as MarkEnrolled / IsEnrolled. + runDir := EnrollmentRunDir(dir) + runDirAbs, err := filepath.Abs(runDir) + if err != nil { + return fmt.Errorf("cannot resolve enrollment run dir: %w", err) + } + + runDir = filepath.Clean(runDirAbs) + if isFilesystemRoot(runDir) { + return fmt.Errorf("refusing to remove enrollment run dir %q", runDir) + } + + knownPaths := []string{ + filepath.Join(cleaned, ConfigFileName), + filepath.Join(cleaned, configFileTmpName), + filepath.Join(cleaned, KeyFileName), + filepath.Join(cleaned, KeyFileName+".tmp"), + filepath.Join(cleaned, KeyFileName+".old"), + filepath.Join(cleaned, pendingPosturesFileName), + filepath.Join(cleaned, pendingPosturesFileName+".tmp"), + filepath.Join(runDir, EnrollmentMarkerName), + } + + var errs error + for _, path := range knownPaths { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = errors.Join(errs, fmt.Errorf("cannot remove %s: %w", path, err)) + } + } + + cacheDir := filepath.Join(cleaned, SigstoreCacheDirName) + if err := os.RemoveAll(cacheDir); err != nil { + errs = errors.Join(errs, fmt.Errorf("cannot remove %s: %w", SigstoreCacheDirName, err)) + } + + // Best-effort: remove the dirs only when empty. Leave them alone when + // foreign files remain or removal is otherwise refused. + _ = os.Remove(cleaned) + _ = os.Remove(runDir) + + return errs +} + +func isFilesystemRoot(path string) bool { + cleaned := filepath.Clean(path) + return cleaned != "" && cleaned == filepath.Dir(cleaned) +}