Add probo-agent binary, installer, and CI
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
10
.github/workflows/make.yaml
vendored
10
.github/workflows/make.yaml
vendored
@@ -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"
|
||||
|
||||
29
.github/workflows/release-probo-agent.yaml
vendored
29
.github/workflows/release-probo-agent.yaml
vendored
@@ -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:
|
||||
|
||||
17
GNUmakefile
17
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)
|
||||
|
||||
|
||||
74
cmd/probo-agent/GNUmakefile
Normal file
74
cmd/probo-agent/GNUmakefile
Normal file
@@ -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
|
||||
81
cmd/probo-agent/desktop.go
Normal file
81
cmd/probo-agent/desktop.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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 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
|
||||
}
|
||||
31
cmd/probo-agent/desktop_stub.go
Normal file
31
cmd/probo-agent/desktop_stub.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 main
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func registerPlatformCommands(_ *cobra.Command) {}
|
||||
|
||||
func registerTrayAutoStart(_ string, _ string) error {
|
||||
return nil
|
||||
}
|
||||
365
cmd/probo-agent/installer/install.sh
Executable file
365
cmd/probo-agent/installer/install.sh
Executable file
@@ -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 "$@"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Installation complete</h1>
|
||||
|
||||
<p>
|
||||
The <code>probo-agent</code> binary is installed and the launchd
|
||||
unit is loaded. If the installer found a pre-staged
|
||||
configuration file at <code>/tmp/probo-agent.conf</code>, the
|
||||
device is already enrolled and the agent is running.
|
||||
The <code>probo-agent</code> binary is installed. The Probo icon should
|
||||
appear in the menu bar (or at your next login). To enroll, open the menu
|
||||
and choose <strong>Enroll via…</strong>, then pick
|
||||
<strong>United States</strong>, <strong>European Union</strong>, or
|
||||
<strong>Self hosted…</strong>. Finish enrollment in your browser; when
|
||||
it succeeds, the menu shows <strong>Connected</strong> and the agent
|
||||
service starts automatically.
|
||||
</p>
|
||||
|
||||
<h2>Enroll this device manually</h2>
|
||||
<p>
|
||||
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 <strong>Connected</strong>.
|
||||
</p>
|
||||
|
||||
<p>Administrators can also finish setup from a Terminal:</p>
|
||||
<pre>sudo probo-agent install \
|
||||
--server https://app.getprobo.com \
|
||||
--enrollment-token <TOKEN></pre>
|
||||
--server https://your-probo-host.example.com \
|
||||
--enrollment-token <ENROLLMENT_TOKEN></pre>
|
||||
|
||||
<h2>Inspect the agent</h2>
|
||||
<pre>sudo probo-agent status
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to the Probo Device Posture Agent</h1>
|
||||
<p>
|
||||
This installer adds <code>probo-agent</code> 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 <code>probo-agent</code>, the menu bar helper, and
|
||||
the <code>probo://</code> 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.
|
||||
</p>
|
||||
|
||||
<h2>What the installer does</h2>
|
||||
<ul>
|
||||
<li>Installs the <code>probo-agent</code> binary to
|
||||
<code>/usr/local/bin/probo-agent</code>.</li>
|
||||
<li>Registers the launchd unit
|
||||
<code>com.probo.agent</code> in
|
||||
<code>/Library/LaunchDaemons</code>.</li>
|
||||
<li>Installs <code>Probo Agent.app</code> to
|
||||
<code>/Applications</code> so browser enrollment deep links
|
||||
(<code>probo://</code>) work.</li>
|
||||
<li>Registers the menu bar helper LaunchAgent
|
||||
<code>com.probo.agent.tray</code> in
|
||||
<code>/Library/LaunchAgents</code>.</li>
|
||||
<li>Creates the persistent state directory
|
||||
<code>/var/lib/probo-agent</code> (root-owned, mode 0700).</li>
|
||||
<li>Enrolls the device automatically when an admin has pre-staged
|
||||
<code>/tmp/probo-agent.conf</code> (typically via an MDM).</li>
|
||||
<li>Enrolls the device and starts the
|
||||
<code>com.probo.agent</code> LaunchDaemon when an admin has
|
||||
pre-staged <code>/tmp/probo-agent.conf</code> (typically via
|
||||
MDM). Otherwise, finish enrollment from the menu bar icon.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What you will need</h2>
|
||||
<ul>
|
||||
<li>Administrator privileges on this Mac.</li>
|
||||
<li>The Probo server URL (e.g.
|
||||
<code>https://app.getprobo.com</code>).</li>
|
||||
<li>The Probo server URL for your deployment (the URL of your Probo
|
||||
console, e.g. <code>https://your-probo-host.example.com</code>).
|
||||
Hosted Probo workspaces use the URL shown in your browser when you
|
||||
sign in.</li>
|
||||
<li>A device enrollment token issued by a workspace administrator.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
Click <strong>Continue</strong> to review the license, then
|
||||
<strong>Install</strong> to proceed.
|
||||
<strong>Install</strong> to proceed. After installation, enroll from
|
||||
the menu bar unless MDM already staged a configuration file.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
cmd/probo-agent/installer/macos/enroll-ui/.gitignore
vendored
Normal file
2
cmd/probo-agent/installer/macos/enroll-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
45
cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl
Normal file
45
cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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">
|
||||
<!--
|
||||
Info.plist for the Probo Agent URL handler app bundle.
|
||||
|
||||
Placeholders are substituted by build-app.sh:
|
||||
|
||||
@@VERSION@@ agent version, e.g. 0.1.0
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>probo-agent-url-handler</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getprobo.agent.url-handler</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Probo Agent</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Probo Agent</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Probo Enrollment</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>probo</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
15
cmd/probo-agent/installer/macos/enroll-ui/Package.swift
Normal file
15
cmd/probo-agent/installer/macos/enroll-ui/Package.swift
Normal file
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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()
|
||||
97
cmd/probo-agent/installer/macos/enroll-ui/build-app.sh
Executable file
97
cmd/probo-agent/installer/macos/enroll-ui/build-app.sh
Executable file
@@ -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 '<string>probo</string>' "${PLIST}"; then
|
||||
echo "error: Info.plist is missing probo URL scheme" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Built ${APP_ROOT}"
|
||||
@@ -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}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${TRAY_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${BINARY}</string>
|
||||
<string>tray</string>
|
||||
<string>--run-dir</string>
|
||||
<string>${RUN_DIR}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
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=<token>
|
||||
# PROBO_SERVER_URL=https://your-probo-host.example.com
|
||||
# PROBO_ENROLLMENT_TOKEN=<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 <URL> --enrollment-token <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
|
||||
|
||||
9
cmd/probo-agent/installer/macos/scripts/preinstall
Normal file
9
cmd/probo-agent/installer/macos/scripts/preinstall
Normal file
@@ -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
|
||||
27
cmd/probo-agent/installer/regions.json
Normal file
27
cmd/probo-agent/installer/regions.json
Normal file
@@ -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."
|
||||
}
|
||||
63
cmd/probo-agent/installer/regions_manifest_test.go
Normal file
63
cmd/probo-agent/installer/regions_manifest_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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 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")
|
||||
}
|
||||
32
cmd/probo-agent/installer/windows/register-protocol.ps1
Normal file
32
cmd/probo-agent/installer/windows/register-protocol.ps1
Normal file
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
#
|
||||
# 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."
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
111
pkg/deviceagent/state_cleanup.go
Normal file
111
pkg/deviceagent/state_cleanup.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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 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)
|
||||
}
|
||||
Reference in New Issue
Block a user