Add probo-agent CLI and deviceagent library
Introduce the standalone device agent binary and shared library for enrollment, posture checks, self-update, and OS service integration. Include build targets, module deps, and release workflow so the agent can ship independently of server changes. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
37
cmd/probo-agent/CHANGELOG.md
Normal file
37
cmd/probo-agent/CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `probo-agent` device posture agent will be
|
||||
documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [0.1.0] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of the Probo device posture agent.
|
||||
- `probo-agent install`, `uninstall`, `run`, `status`, `collect` CLI
|
||||
commands.
|
||||
- Managed OS service installation for macOS (`launchd`), Linux
|
||||
(`systemd`), FreeBSD (`rc.d`), and Windows (Service Control Manager).
|
||||
- v1 posture check set per OS: disk encryption, screen lock, firewall,
|
||||
time sync, OS version, auto update, password policy, remote login.
|
||||
- Enrollment / heartbeat / posture reporting against the new
|
||||
`/api/agent/v1` Probo REST API.
|
||||
- Auto-update: the agent periodically checks GitHub Releases for a
|
||||
newer `probo-agent/v*` tag and self-installs it. The running binary
|
||||
is swapped atomically and the OS service supervisor is asked to
|
||||
restart via a dedicated exit code (`75`).
|
||||
- Cosign signature verification of every release before installation:
|
||||
`checksums.txt.bundle` is verified with `sigstore-go` against the
|
||||
Sigstore public-good trust root, pinned to the GitHub Actions OIDC
|
||||
identity for `release-probo-agent.yaml` on a tagged commit. Releases
|
||||
without a Sigstore bundle, with an invalid bundle, or signed by a
|
||||
different workflow are rejected without touching the running
|
||||
binary.
|
||||
- `probo-agent update [--check]` command for manual one-shot upgrade.
|
||||
- `probo-agent install --no-auto-update` flag to opt out of automatic
|
||||
upgrades; the flag is persisted in `config.json` as
|
||||
`updates_disabled`.
|
||||
- `probo-agent status` now reports the configured update interval and
|
||||
whether auto-update is enabled.
|
||||
1
cmd/probo-agent/VERSION
Normal file
1
cmd/probo-agent/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
56
cmd/probo-agent/installer/macos/Distribution.xml.tmpl
Normal file
56
cmd/probo-agent/installer/macos/Distribution.xml.tmpl
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Productbuild distribution definition for the Probo device posture
|
||||
agent.
|
||||
|
||||
Placeholders are substituted by build.sh:
|
||||
|
||||
@@VERSION@@ agent version, e.g. 0.1.0
|
||||
@@PKG_ARCH@@ payload pkgbuild architecture (x86_64 or arm64)
|
||||
@@HOST_ARCHS@@ host arch filter used by Installer.app
|
||||
-->
|
||||
<installer-gui-script minSpecVersion="2">
|
||||
<title>Probo Device Posture Agent @@VERSION@@</title>
|
||||
|
||||
<!--
|
||||
Single-pane install. Customization is disabled because the agent
|
||||
is a single component and we do not want users to opt out of the
|
||||
postinstall script.
|
||||
-->
|
||||
<options customize="never"
|
||||
require-scripts="true"
|
||||
rootVolumeOnly="true"
|
||||
hostArchitectures="@@HOST_ARCHS@@"/>
|
||||
|
||||
<!--
|
||||
Refuse installation on macOS older than 11 (Big Sur). The agent
|
||||
relies on launchd bootstrap/bootout semantics and the modern
|
||||
`system_profiler` output that earlier releases do not provide.
|
||||
-->
|
||||
<volume-check>
|
||||
<allowed-os-versions>
|
||||
<os-version min="11.0"/>
|
||||
</allowed-os-versions>
|
||||
</volume-check>
|
||||
|
||||
<welcome file="welcome.html" mime-type="text/html"/>
|
||||
<license file="license.txt" mime-type="text/plain"/>
|
||||
<conclusion file="conclusion.html" mime-type="text/html"/>
|
||||
|
||||
<pkg-ref id="com.getprobo.agent"
|
||||
version="@@VERSION@@"
|
||||
onConclusion="none">probo-agent-component.pkg</pkg-ref>
|
||||
|
||||
<choices-outline>
|
||||
<line choice="default">
|
||||
<line choice="com.getprobo.agent"/>
|
||||
</line>
|
||||
</choices-outline>
|
||||
|
||||
<choice id="default"/>
|
||||
<choice id="com.getprobo.agent"
|
||||
title="Probo Device Posture Agent"
|
||||
description="Installs probo-agent to /usr/local/bin and the launchd unit to /Library/LaunchDaemons.">
|
||||
<pkg-ref id="com.getprobo.agent"/>
|
||||
</choice>
|
||||
</installer-gui-script>
|
||||
62
cmd/probo-agent/installer/macos/Resources/conclusion.html
Normal file
62
cmd/probo-agent/installer/macos/Resources/conclusion.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Installation complete</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1d1d1f;
|
||||
margin: 24px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
h1 { font-size: 18px; margin-bottom: 6px; }
|
||||
h2 { font-size: 14px; margin-top: 18px; margin-bottom: 4px; }
|
||||
code, pre {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
background: #f5f5f7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
code { padding: 2px 4px; }
|
||||
pre {
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<h2>Enroll this device manually</h2>
|
||||
<p>
|
||||
If you installed without a pre-staged configuration, finish the
|
||||
setup from a Terminal:
|
||||
</p>
|
||||
<pre>sudo probo-agent install \
|
||||
--server https://app.getprobo.com \
|
||||
--enrollment-token <TOKEN></pre>
|
||||
|
||||
<h2>Inspect the agent</h2>
|
||||
<pre>sudo probo-agent status
|
||||
sudo probo-agent collect</pre>
|
||||
|
||||
<h2>Uninstall</h2>
|
||||
<pre>sudo probo-agent uninstall</pre>
|
||||
|
||||
<p>
|
||||
Logs are written to <code>/var/log/probo-agent.log</code>. The
|
||||
installer's own log lives at
|
||||
<code>/var/log/probo-agent-install.log</code>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
62
cmd/probo-agent/installer/macos/Resources/welcome.html
Normal file
62
cmd/probo-agent/installer/macos/Resources/welcome.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Probo Device Posture Agent</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1d1d1f;
|
||||
margin: 24px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
h1 { font-size: 18px; margin-bottom: 6px; }
|
||||
h2 { font-size: 14px; margin-top: 18px; margin-bottom: 4px; }
|
||||
code, pre {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
background: #f5f5f7;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
pre { padding: 8px 10px; overflow-x: auto; }
|
||||
ul { margin: 4px 0 8px 18px; padding: 0; }
|
||||
li { margin: 2px 0; }
|
||||
</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.
|
||||
</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.getprobo.agent</code> in
|
||||
<code>/Library/LaunchDaemons</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>
|
||||
</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>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.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
112
cmd/probo-agent/installer/macos/build.sh
Executable file
112
cmd/probo-agent/installer/macos/build.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build a Probo device posture agent macOS installer (.pkg) from a
|
||||
# pre-built `probo-agent` binary.
|
||||
#
|
||||
# Required arguments:
|
||||
# --binary PATH Path to a compiled probo-agent binary.
|
||||
# --arch ARCH Target architecture: amd64 or arm64.
|
||||
# --version VER Agent version, e.g. 0.1.0. Defaults to the
|
||||
# content of cmd/probo-agent/VERSION.
|
||||
# --output PATH Output .pkg path. Defaults to
|
||||
# dist/probo-agent_${VER}_${OS}.pkg.
|
||||
#
|
||||
# The resulting flat distribution package is unsigned. Apple
|
||||
# Developer ID signing + notarization are out of scope for this
|
||||
# script; consumers can chain `productsign` and `xcrun notarytool`
|
||||
# afterwards.
|
||||
#
|
||||
# Must run on macOS: pkgbuild and productbuild are Apple-only tools.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
|
||||
|
||||
BINARY=""
|
||||
ARCH=""
|
||||
VERSION=""
|
||||
OUTPUT=""
|
||||
IDENTIFIER="com.getprobo.agent"
|
||||
|
||||
usage() {
|
||||
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--binary) BINARY="$2"; shift 2 ;;
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--identifier) IDENTIFIER="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown flag: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${BINARY}" ] || [ ! -x "${BINARY}" ]; then
|
||||
echo "error: --binary <path-to-probo-agent> is required and must be executable" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "${ARCH}" in
|
||||
amd64) PKG_ARCH="x86_64" ;;
|
||||
arm64) PKG_ARCH="arm64" ;;
|
||||
"") echo "error: --arch (amd64|arm64) is required" >&2; exit 2 ;;
|
||||
*) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;;
|
||||
esac
|
||||
if [ -z "${VERSION}" ]; then
|
||||
VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")"
|
||||
fi
|
||||
if [ -z "${OUTPUT}" ]; then
|
||||
mkdir -p "${REPO_ROOT}/dist"
|
||||
OUTPUT="${REPO_ROOT}/dist/probo-agent_${VERSION}_darwin_${PKG_ARCH}.pkg"
|
||||
fi
|
||||
|
||||
if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null 2>&1; then
|
||||
echo "error: pkgbuild and productbuild are required (run on macOS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAGE="$(mktemp -d -t probo-agent-pkg)"
|
||||
trap 'rm -rf "${STAGE}"' EXIT
|
||||
|
||||
PAYLOAD="${STAGE}/payload"
|
||||
SCRIPTS="${STAGE}/scripts"
|
||||
RESOURCES="${STAGE}/Resources"
|
||||
mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}"
|
||||
|
||||
install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent"
|
||||
|
||||
install -m 0755 "${SCRIPT_DIR}/scripts/postinstall" "${SCRIPTS}/postinstall"
|
||||
|
||||
cp "${SCRIPT_DIR}/Resources/welcome.html" "${RESOURCES}/welcome.html"
|
||||
cp "${SCRIPT_DIR}/Resources/conclusion.html" "${RESOURCES}/conclusion.html"
|
||||
cp "${REPO_ROOT}/LICENSE" "${RESOURCES}/license.txt"
|
||||
|
||||
# Component package: payload + scripts only.
|
||||
COMPONENT_PKG="${STAGE}/probo-agent-component.pkg"
|
||||
pkgbuild \
|
||||
--root "${PAYLOAD}" \
|
||||
--scripts "${SCRIPTS}" \
|
||||
--identifier "${IDENTIFIER}" \
|
||||
--version "${VERSION}" \
|
||||
--install-location "/" \
|
||||
"${COMPONENT_PKG}"
|
||||
|
||||
# Render Distribution.xml from its template.
|
||||
DISTRIBUTION="${STAGE}/Distribution.xml"
|
||||
sed \
|
||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
|
||||
-e "s|@@HOST_ARCHS@@|${PKG_ARCH}|g" \
|
||||
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
|
||||
|
||||
mkdir -p "$(dirname "${OUTPUT}")"
|
||||
productbuild \
|
||||
--distribution "${DISTRIBUTION}" \
|
||||
--package-path "${STAGE}" \
|
||||
--resources "${RESOURCES}" \
|
||||
"${OUTPUT}"
|
||||
|
||||
echo "Built ${OUTPUT}"
|
||||
92
cmd/probo-agent/installer/macos/scripts/postinstall
Executable file
92
cmd/probo-agent/installer/macos/scripts/postinstall
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# probo-agent macOS PKG postinstall script.
|
||||
#
|
||||
# Runs as root inside the macOS Installer.app sandbox after the
|
||||
# payload has been laid down. Standard pkgbuild positional args:
|
||||
#
|
||||
# $1 = full path to the component package
|
||||
# $2 = full path to the install location (selected target)
|
||||
# $3 = mountpoint of the destination volume
|
||||
# $4 = root directory ("/" for the target volume)
|
||||
#
|
||||
# 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.
|
||||
|
||||
set -u
|
||||
|
||||
LOG_FILE="/var/log/probo-agent-install.log"
|
||||
BINARY="/usr/local/bin/probo-agent"
|
||||
STATE_DIR="/var/lib/probo-agent"
|
||||
CONF_FILE="/tmp/probo-agent.conf"
|
||||
|
||||
# Mirror everything to the install log. We keep stdout/stderr open
|
||||
# too so failures still surface in macOS Installer.app's log pane.
|
||||
mkdir -p "$(dirname "${LOG_FILE}")"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
echo
|
||||
echo "=== probo-agent postinstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
|
||||
echo "pkg=$1 target=$2 mount=$3 root=$4"
|
||||
|
||||
if [ ! -x "${BINARY}" ]; then
|
||||
echo "error: expected binary not found at ${BINARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${STATE_DIR}"
|
||||
chown root:wheel "${STATE_DIR}"
|
||||
chmod 0700 "${STATE_DIR}"
|
||||
|
||||
# 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_NO_AUTO_UPDATE=true
|
||||
#
|
||||
# We source the file in a subshell so a malformed line can never
|
||||
# leak variables into our env, then validate the values we care
|
||||
# about.
|
||||
if [ -f "${CONF_FILE}" ]; then
|
||||
echo "Found ${CONF_FILE}, attempting unattended enrollment."
|
||||
|
||||
eval "$(
|
||||
set -e
|
||||
# shellcheck source=/dev/null
|
||||
. "${CONF_FILE}"
|
||||
printf 'CONF_SERVER=%q\n' "${PROBO_SERVER_URL:-}"
|
||||
printf 'CONF_TOKEN=%q\n' "${PROBO_ENROLLMENT_TOKEN:-}"
|
||||
printf 'CONF_NOUPDATE=%q\n' "${PROBO_NO_AUTO_UPDATE:-}"
|
||||
)"
|
||||
|
||||
if [ -z "${CONF_SERVER}" ] || [ -z "${CONF_TOKEN}" ]; then
|
||||
echo "warning: ${CONF_FILE} is missing PROBO_SERVER_URL or PROBO_ENROLLMENT_TOKEN; skipping enrollment."
|
||||
else
|
||||
EXTRA_FLAGS=()
|
||||
case "${CONF_NOUPDATE}" in
|
||||
1|true|TRUE|yes|YES) EXTRA_FLAGS+=("--no-auto-update") ;;
|
||||
esac
|
||||
|
||||
if "${BINARY}" install \
|
||||
--server "${CONF_SERVER}" \
|
||||
--enrollment-token "${CONF_TOKEN}" \
|
||||
"${EXTRA_FLAGS[@]}"; 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 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>"
|
||||
fi
|
||||
|
||||
echo "=== postinstall done ==="
|
||||
exit 0
|
||||
372
cmd/probo-agent/main.go
Normal file
372
cmd/probo-agent/main.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2025-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.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/service"
|
||||
"go.probo.inc/probo/pkg/deviceagent/update"
|
||||
|
||||
// Side-effect import: registers per-OS posture checks.
|
||||
_ "go.probo.inc/probo/pkg/deviceagent/checks"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// restartExitCode signals the OS service supervisor that the agent
|
||||
// process exited because its binary was replaced and needs to be
|
||||
// restarted. The value matches sysexits.h's EX_TEMPFAIL and is
|
||||
// whitelisted in the systemd unit so the unit does not enter the
|
||||
// "failed" state on a normal self-update.
|
||||
const restartExitCode = 75
|
||||
|
||||
func main() {
|
||||
// Best-effort cleanup of a previous-version binary left aside by
|
||||
// a Windows self-update. No-op on Unix.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
update.CleanupAfterRestart(exe)
|
||||
}
|
||||
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
if errors.Is(err, deviceagent.ErrRestartRequired) {
|
||||
os.Exit(restartExitCode)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "probo-agent: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "probo-agent",
|
||||
Short: "Probo device posture agent",
|
||||
Long: "probo-agent runs as a managed OS service, reporting device posture to Probo.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
root.PersistentFlags().StringP("dir", "d", "", "agent config / keystore directory (defaults to platform-specific path)")
|
||||
|
||||
root.AddCommand(newInstallCmd())
|
||||
root.AddCommand(newUninstallCmd())
|
||||
root.AddCommand(newRunCmd())
|
||||
root.AddCommand(newStatusCmd())
|
||||
root.AddCommand(newCollectCmd())
|
||||
root.AddCommand(newUpdateCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
// newUpdater returns an Updater scoped to the running binary, or nil
|
||||
// when self-update cannot be performed (unresolvable binary path).
|
||||
//
|
||||
// dir is the agent state directory, used to host the Sigstore TUF
|
||||
// metadata cache for cosign bundle verification.
|
||||
func newUpdater(logger *log.Logger, dir string) *update.Updater {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil || exePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return update.New(
|
||||
version,
|
||||
exePath,
|
||||
fmt.Sprintf("probo-agent/%s", version),
|
||||
filepath.Join(dir, "sigstore-cache"),
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveDir(cmd *cobra.Command) string {
|
||||
dir, _ := cmd.Flags().GetString("dir")
|
||||
if dir != "" {
|
||||
return dir
|
||||
}
|
||||
|
||||
return deviceagent.DefaultConfigDir()
|
||||
}
|
||||
|
||||
func newAgentLogger() *log.Logger {
|
||||
return log.NewLogger(
|
||||
log.WithName("probo-agent"),
|
||||
log.WithOutput(os.Stderr),
|
||||
)
|
||||
}
|
||||
|
||||
func newInstallCmd() *cobra.Command {
|
||||
var (
|
||||
serverURL string
|
||||
enrollmentToken string
|
||||
skipService bool
|
||||
noAutoUpdate bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Enroll this device and install the agent as a managed OS service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if serverURL == "" {
|
||||
return errors.New("--server is required")
|
||||
}
|
||||
|
||||
if enrollmentToken == "" {
|
||||
if v := os.Getenv("PROBO_TOKEN"); v != "" {
|
||||
enrollmentToken = v
|
||||
}
|
||||
}
|
||||
|
||||
if enrollmentToken == "" {
|
||||
return errors.New("--enrollment-token (or PROBO_TOKEN env var) is required")
|
||||
}
|
||||
|
||||
dir := resolveDir(cmd)
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
agent := deviceagent.New(dir, version, newAgentLogger())
|
||||
resp, err := agent.EnrollNewDevice(ctx, strings.TrimRight(serverURL, "/"), enrollmentToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enrollment failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Enrolled device %s (heartbeat %ds, posture %ds)\n",
|
||||
resp.DeviceID, resp.HeartbeatSeconds, resp.PostureSeconds)
|
||||
|
||||
if noAutoUpdate {
|
||||
if err := persistAutoUpdate(dir, false); err != nil {
|
||||
return fmt.Errorf("cannot persist auto-update preference: %w", err)
|
||||
}
|
||||
fmt.Println("Auto-update disabled.")
|
||||
}
|
||||
|
||||
if skipService {
|
||||
fmt.Println("Service installation skipped (--skip-service).")
|
||||
return nil
|
||||
}
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve current executable path: %w", err)
|
||||
}
|
||||
|
||||
if err := service.Install(
|
||||
service.Config{
|
||||
ExePath: exePath,
|
||||
Dir: dir,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot install OS service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Service installed and started.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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().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")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// persistAutoUpdate flips the UpdatesDisabled flag in the agent's
|
||||
// on-disk config without disturbing other fields.
|
||||
func persistAutoUpdate(dir string, enabled bool) error {
|
||||
cfg, err := deviceagent.LoadConfig(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.UpdatesDisabled = !enabled
|
||||
return deviceagent.SaveConfig(dir, cfg)
|
||||
}
|
||||
|
||||
func newUninstallCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Stop the service, unenroll this device, and remove local state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := resolveDir(cmd)
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
agent := deviceagent.New(dir, version, newAgentLogger())
|
||||
if err := agent.Unenroll(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: unenroll failed: %v\n", err)
|
||||
}
|
||||
|
||||
if err := service.Uninstall(service.Config{Dir: dir}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: service uninstall failed: %v\n", err)
|
||||
}
|
||||
|
||||
_ = os.Remove(deviceagent.ConfigPath(dir))
|
||||
fmt.Println("Uninstalled.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRunCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run the agent in the foreground (used by the OS service unit)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := resolveDir(cmd)
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger := newAgentLogger()
|
||||
agent := deviceagent.New(dir, version, logger)
|
||||
agent.Updater = newUpdater(logger, dir)
|
||||
err := agent.Run(ctx)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Print the agent's local state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := resolveDir(cmd)
|
||||
cfg, err := deviceagent.LoadConfig(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
haveKey := true
|
||||
if _, err := deviceagent.LoadAPIKey(dir); err != nil {
|
||||
haveKey = false
|
||||
}
|
||||
|
||||
fmt.Printf("Server URL: %s\n", cfg.ServerURL)
|
||||
fmt.Printf("Device ID: %s\n", cfg.DeviceID)
|
||||
fmt.Printf("Heartbeat interval: %s\n", cfg.HeartbeatInterval)
|
||||
fmt.Printf("Posture interval: %s\n", cfg.PostureInterval)
|
||||
fmt.Printf("Update interval: %s\n", cfg.UpdateInterval)
|
||||
fmt.Printf("Auto-update enabled: %v\n", !cfg.UpdatesDisabled)
|
||||
fmt.Printf("API key on disk: %v\n", haveKey)
|
||||
fmt.Printf("Config directory: %s\n", dir)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCollectCmd() *cobra.Command {
|
||||
var (
|
||||
once bool
|
||||
asJSON bool
|
||||
printDir bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "collect",
|
||||
Short: "Run the posture check set once and print results (no server push)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := resolveDir(cmd)
|
||||
if printDir {
|
||||
fmt.Println(dir)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
agent := deviceagent.New(dir, version, newAgentLogger())
|
||||
results := agent.CollectOnce(ctx)
|
||||
|
||||
if asJSON {
|
||||
return json.NewEncoder(os.Stdout).Encode(results)
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
fmt.Printf("%-20s %-15s %v\n", r.CheckKey, r.Status, r.Evidence)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&once, "once", true, "(default true) run the check set once and exit")
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON instead of the human-readable table")
|
||||
cmd.Flags().BoolVar(&printDir, "print-dir", false, "print the resolved agent dir before the results")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUpdateCmd() *cobra.Command {
|
||||
var checkOnly bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check GitHub for a newer agent release and install it in place",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := resolveDir(cmd)
|
||||
logger := newAgentLogger()
|
||||
updater := newUpdater(logger, dir)
|
||||
if updater == nil {
|
||||
return errors.New("cannot resolve current executable path")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rel, err := updater.CheckLatest(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, update.ErrNoUpdateAvailable) {
|
||||
fmt.Printf("probo-agent is up to date (version %s).\n", version)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot check for updates: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Update available: %s -> %s\n", version, rel.Version)
|
||||
if checkOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := updater.Apply(ctx, rel); err != nil {
|
||||
return fmt.Errorf("cannot apply update: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installed probo-agent %s. Restart the service to use it.\n", rel.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&checkOnly, "check", false, "only print the available version, do not install it")
|
||||
|
||||
return cmd
|
||||
}
|
||||
Reference in New Issue
Block a user