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:
161
.github/workflows/release-probo-agent.yaml
vendored
Normal file
161
.github/workflows/release-probo-agent.yaml
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
name: "Release probo-agent"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "probo-agent/v*"
|
||||
|
||||
permissions:
|
||||
contents: "read"
|
||||
|
||||
jobs:
|
||||
build-binary:
|
||||
name: "binary (${{ matrix.goos }}/${{ matrix.goarch }})"
|
||||
runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache"
|
||||
permissions:
|
||||
contents: "read"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { goos: linux, goarch: amd64 }
|
||||
- { goos: linux, goarch: arm64 }
|
||||
- { goos: darwin, goarch: amd64 }
|
||||
- { goos: darwin, goarch: arm64 }
|
||||
- { goos: windows, goarch: amd64 }
|
||||
- { goos: windows, goarch: arm64 }
|
||||
- { goos: freebsd, goarch: amd64 }
|
||||
- { goos: freebsd, goarch: arm64 }
|
||||
steps:
|
||||
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: "runs-on/action@742bf56072eb4845a0f94b3394673e4903c90ff0" # v2
|
||||
- uses: "./.github/actions/setup"
|
||||
with:
|
||||
node: "false"
|
||||
- name: "Build binary"
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: "${{ matrix.goos }}"
|
||||
GOARCH: "${{ matrix.goarch }}"
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME##*/v}"
|
||||
EXT=""
|
||||
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
|
||||
- name: "Create archive"
|
||||
env:
|
||||
GOOS: "${{ matrix.goos }}"
|
||||
GOARCH: "${{ matrix.goarch }}"
|
||||
run: |
|
||||
case "$GOOS" in
|
||||
linux) OS="Linux" ;;
|
||||
darwin) OS="Darwin" ;;
|
||||
windows) OS="Windows" ;;
|
||||
freebsd) OS="Freebsd" ;;
|
||||
esac
|
||||
case "$GOARCH" in
|
||||
amd64) ARCH="x86_64" ;;
|
||||
*) ARCH="$GOARCH" ;;
|
||||
esac
|
||||
EXT=""
|
||||
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||
|
||||
mkdir -p archives
|
||||
AGENT_DIR="probo-agent_${OS}_${ARCH}"
|
||||
mkdir -p "staging/${AGENT_DIR}"
|
||||
cp "dist/probo-agent${EXT}" README.md LICENSE "staging/${AGENT_DIR}/"
|
||||
if [ -f cmd/probo-agent/CHANGELOG.md ]; then
|
||||
cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/"
|
||||
fi
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
(cd staging && zip -r "../archives/${AGENT_DIR}.zip" "${AGENT_DIR}")
|
||||
else
|
||||
tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}"
|
||||
fi
|
||||
- uses: "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7
|
||||
with:
|
||||
name: "archive-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
path: "archives/"
|
||||
retention-days: 1
|
||||
|
||||
github-release:
|
||||
name: "github-release"
|
||||
needs: [build-binary]
|
||||
runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache"
|
||||
permissions:
|
||||
contents: "write"
|
||||
id-token: "write"
|
||||
attestations: "write"
|
||||
steps:
|
||||
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: "runs-on/action@742bf56072eb4845a0f94b3394673e4903c90ff0" # v2
|
||||
- uses: "sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad" # v4.0.0
|
||||
- uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" # v8
|
||||
with:
|
||||
pattern: "archive-*"
|
||||
path: "archives"
|
||||
merge-multiple: true
|
||||
- name: "Generate checksums and sign"
|
||||
run: |
|
||||
cd archives
|
||||
sha256sum *.tar.gz *.zip > checksums.txt
|
||||
cosign sign-blob --bundle="checksums.txt.bundle" checksums.txt --yes
|
||||
- name: "Generate SBOM"
|
||||
uses: "anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610" # v0.24.0
|
||||
with:
|
||||
path: ./cmd/probo-agent
|
||||
format: cyclonedx-json
|
||||
output-file: sbom.json
|
||||
- name: "Run vulnerability scan"
|
||||
uses: "anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2" # v7.4.0
|
||||
with:
|
||||
sbom: "sbom.json"
|
||||
fail-build: true
|
||||
severity-cutoff: critical
|
||||
- name: "Attest SBOM for archives"
|
||||
uses: "actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e" # v4
|
||||
with:
|
||||
subject-path: "archives/*.tar.gz, archives/*.zip"
|
||||
sbom-path: "sbom.json"
|
||||
- name: "Attest build provenance for archives"
|
||||
uses: "actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4
|
||||
with:
|
||||
subject-path: "archives/*.tar.gz, archives/*.zip"
|
||||
- name: "Extract release notes"
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME##*/v}"
|
||||
if [ -f cmd/probo-agent/CHANGELOG.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
/^## \[/ { if (found) exit; if ($0 ~ "\\[" ver "\\]") found=1 }
|
||||
found
|
||||
' cmd/probo-agent/CHANGELOG.md > release-notes.md
|
||||
else
|
||||
echo "probo-agent ${VERSION}" > release-notes.md
|
||||
fi
|
||||
- name: "Create GitHub release"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PRERELEASE_FLAG=""
|
||||
if echo "${GITHUB_REF_NAME}" | grep -qE '(alpha|beta|rc)'; then
|
||||
PRERELEASE_FLAG="--prerelease"
|
||||
fi
|
||||
|
||||
gh release delete "${GITHUB_REF_NAME}" --yes 2>/dev/null || true
|
||||
|
||||
gh release create "${GITHUB_REF_NAME}" \
|
||||
--title "${GITHUB_REF_NAME}" \
|
||||
--notes-file release-notes.md \
|
||||
$PRERELEASE_FLAG \
|
||||
archives/* sbom.json
|
||||
- uses: "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7
|
||||
with:
|
||||
name: "sbom"
|
||||
path: "sbom.json"
|
||||
retention-days: 30
|
||||
34
GNUmakefile
34
GNUmakefile
@@ -25,12 +25,12 @@ DOCKER_COMPOSE= $(DOCKER) compose -f compose.yaml $(DOCKER_COMPOSE_FLAGS)
|
||||
PRB_VERSION= $(shell cat cmd/prb/VERSION)
|
||||
PROBOD_VERSION= $(shell cat cmd/probod/VERSION)
|
||||
PROBOD_BOOTSTRAP_VERSION=$(shell cat cmd/probod-bootstrap/VERSION)
|
||||
PROBOCTL_VERSION= $(shell cat cmd/proboctl/VERSION)
|
||||
PROBO_AGENT_VERSION= $(shell cat cmd/probo-agent/VERSION)
|
||||
|
||||
PRB_LDFLAGS= -ldflags "-X 'main.version=$(PRB_VERSION)'"
|
||||
PROBOD_LDFLAGS= -ldflags "-X 'main.version=$(PROBOD_VERSION)' -X 'main.env=prod'"
|
||||
PROBOD_BOOTSTRAP_LDFLAGS=-ldflags "-X 'main.version=$(PROBOD_BOOTSTRAP_VERSION)'"
|
||||
PROBOCTL_LDFLAGS= -ldflags "-X 'main.version=$(PROBOCTL_VERSION)'"
|
||||
PROBO_AGENT_LDFLAGS= -ldflags "-X 'main.version=$(PROBO_AGENT_VERSION)'"
|
||||
|
||||
GCFLAGS= -gcflags="-e"
|
||||
|
||||
@@ -49,14 +49,7 @@ TEST_FLAGS?= -race -cover -coverprofile=coverage.out
|
||||
E2E_CONFIG ?= $(CURDIR)/e2e/console/testdata/config.yaml
|
||||
E2E_COVER_DIR ?= $(CURDIR)/coverage/e2e
|
||||
|
||||
DOCKER_REGISTRY= artifact.probo.inc
|
||||
DOCKER_PROXY= $(DOCKER_REGISTRY)/dockerhub
|
||||
DOCKER_BASE_DIGEST= sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b
|
||||
DOCKER_BASE_IMAGE= ubuntu:24.04@$(DOCKER_BASE_DIGEST)
|
||||
# Harbor proxy resolves digest refs as library/<name>@sha256:..., not library/<name>:tag@sha256:...
|
||||
DOCKER_PROXY_BASE_IMAGE= $(DOCKER_PROXY)/library/ubuntu@$(DOCKER_BASE_DIGEST)
|
||||
DOCKER_IMAGE_NAME= $(DOCKER_REGISTRY)/probo/probo
|
||||
HELM_CHART_OCI= oci://$(DOCKER_REGISTRY)/probo
|
||||
DOCKER_IMAGE_NAME= ghcr.io/getprobo/probo
|
||||
DOCKER_TAG_NAME?= latest
|
||||
|
||||
GENERATED= pkg/server/api/connect/v1/schema/schema.go \
|
||||
@@ -82,8 +75,8 @@ PRB_SRC= cmd/prb/main.go
|
||||
PROBOD_BOOTSTRAP_BIN= bin/probod-bootstrap
|
||||
PROBOD_BOOTSTRAP_SRC= cmd/probod-bootstrap/main.go
|
||||
|
||||
PROBOCTL_BIN= bin/proboctl
|
||||
PROBOCTL_SRC= cmd/proboctl/main.go
|
||||
PROBO_AGENT_BIN= bin/probo-agent
|
||||
PROBO_AGENT_SRC= cmd/probo-agent/main.go
|
||||
|
||||
ifdef WITH_APPS
|
||||
GENERATED += relay
|
||||
@@ -184,7 +177,7 @@ coverage-combined: coverage-report test-e2e-coverage ## Generate combined covera
|
||||
$(GO) tool cover -html=coverage-combined.out -o=coverage-combined.html
|
||||
|
||||
.PHONY: build
|
||||
build: $(PROBOD_BIN) bin/prb bin/probod-bootstrap bin/proboctl
|
||||
build: $(PROBOD_BIN) bin/prb bin/probod-bootstrap
|
||||
|
||||
CFG_DEV_OAUTH2_KEY = cfg/.dev-oauth2-signing-key.pem
|
||||
DEV_ENV = .env
|
||||
@@ -264,9 +257,9 @@ bin/prb:
|
||||
$(PROBOD_BOOTSTRAP_BIN):
|
||||
$(GO_BUILD) $(PROBOD_BOOTSTRAP_LDFLAGS) -o $(PROBOD_BOOTSTRAP_BIN) $(PROBOD_BOOTSTRAP_SRC)
|
||||
|
||||
.PHONY: bin/proboctl
|
||||
bin/proboctl:
|
||||
$(GO_BUILD) $(PROBOCTL_LDFLAGS) -o $(PROBOCTL_BIN) $(PROBOCTL_SRC)
|
||||
.PHONY: $(PROBO_AGENT_BIN)
|
||||
$(PROBO_AGENT_BIN):
|
||||
$(GO_BUILD) $(PROBO_AGENT_LDFLAGS) -o $(PROBO_AGENT_BIN) $(PROBO_AGENT_SRC)
|
||||
|
||||
.PHONY: @probo/emails
|
||||
@probo/emails:
|
||||
@@ -340,15 +333,6 @@ genmodels: ## Refresh LLM model registry from OpenRouter
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: fix
|
||||
fix: fix-go ## Auto-fix Go code
|
||||
|
||||
.PHONY: fix-go
|
||||
fix-go: generate embed ## Auto-fix Go code (format, go fix, lint --fix)
|
||||
gofmt -w apps cmd packages pkg e2e
|
||||
$(GO_BASE) fix -omitzero=false ./apps/... ./cmd/... ./packages/... ./pkg/... ./e2e/...
|
||||
$(GOLINTCMD) run --fix ./...
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: fmt-go ## Format Go code
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
71
go.mod
71
go.mod
@@ -20,7 +20,6 @@ require (
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
@@ -31,6 +30,7 @@ require (
|
||||
github.com/pires/go-proxyproto v0.12.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/scim2/filter-parser/v2 v2.2.1
|
||||
github.com/sigstore/sigstore-go v1.1.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
github.com/vikstrous/dataloadgen v0.0.10
|
||||
@@ -51,13 +51,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
@@ -68,35 +67,61 @@ require (
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/go-openapi/analysis v0.24.1 // indirect
|
||||
github.com/go-openapi/errors v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/loads v0.23.2 // indirect
|
||||
github.com/go-openapi/runtime v0.29.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/strfmt v0.25.0 // indirect
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-openapi/validate v0.25.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.3.2 // indirect
|
||||
github.com/google/go-containerregistry v0.20.7 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/in-toto/attestation v1.1.2 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/sigstore/protobuf-specs v0.5.0 // indirect
|
||||
github.com/sigstore/rekor v1.4.3 // indirect
|
||||
github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect
|
||||
github.com/sigstore/sigstore v1.10.0 // indirect
|
||||
github.com/sigstore/timestamp-authority/v2 v2.0.3 // indirect
|
||||
github.com/theupdateframework/go-tuf/v2 v2.3.0 // indirect
|
||||
github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect
|
||||
github.com/transparency-dev/merkle v0.0.2 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -122,10 +147,10 @@ require (
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/di-wu/parser v0.3.0 // indirect
|
||||
github.com/di-wu/xsd-datetime v1.0.0 // indirect
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
|
||||
github.com/dnephin/pflag v1.0.7 // indirect
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
@@ -165,7 +190,7 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
@@ -191,7 +216,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/mod v0.34.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0
|
||||
|
||||
321
go.sum
321
go.sum
@@ -1,40 +1,61 @@
|
||||
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
|
||||
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k=
|
||||
cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
|
||||
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
|
||||
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4nnN6rRQ+j/NIg=
|
||||
github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
@@ -43,6 +64,8 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2c
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8=
|
||||
@@ -55,8 +78,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/ku
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.48.2 h1:aL8Y/AbB6I+uw0MjLbdo68NQ8t5lNs3CY3S848HpETk=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.48.2/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 h1:mxuT1xE+dI54NW3RkNjP8DUT5HXqbkiAFvfdyDFwE5c=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -73,12 +104,16 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
@@ -125,26 +160,31 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI=
|
||||
github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q=
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
|
||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/di-wu/parser v0.3.0 h1:NMOvy5ifswgt4gsdhySVcKOQtvjC43cHZIfViWctqQY=
|
||||
github.com/di-wu/parser v0.3.0/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
|
||||
github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
|
||||
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
@@ -153,10 +193,6 @@ github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
@@ -167,20 +203,12 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 h1:bn2ml0JxH4DtQqi+CZ+ZPUBo1i3K+4xD0rHczpvpqFk=
|
||||
github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3/go.mod h1:njybYNBd7EDyRMan05ticVQjPP6ThiuyCDbtRh9+e8A=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -188,8 +216,58 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM=
|
||||
github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84=
|
||||
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
|
||||
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4=
|
||||
github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY=
|
||||
github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0=
|
||||
github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
|
||||
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
|
||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw=
|
||||
github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
@@ -200,24 +278,30 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A=
|
||||
github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I=
|
||||
github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js=
|
||||
github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
|
||||
@@ -228,16 +312,44 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
|
||||
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=
|
||||
github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
|
||||
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
|
||||
github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM=
|
||||
github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
|
||||
github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E=
|
||||
github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM=
|
||||
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
|
||||
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
@@ -252,34 +364,32 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8=
|
||||
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY=
|
||||
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/letsencrypt/boulder v0.20251110.0 h1:J8MnKICeilO91dyQ2n5eBbab24neHzUpYMUIOdOtbjc=
|
||||
github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -293,8 +403,12 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -305,24 +419,29 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
|
||||
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pdfcpu/pdfcpu v0.12.0 h1:GonU1Ub45kKo/LdakJhaBA0NTTvBA7KGs3bfmEU1osU=
|
||||
github.com/pdfcpu/pdfcpu v0.12.0/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps=
|
||||
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
@@ -338,17 +457,44 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
|
||||
github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A=
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/scim2/filter-parser/v2 v2.2.1 h1:akm05YVRosO9sy68mTOfCXmvlqlGzFecA2GA0K8kFA4=
|
||||
github.com/scim2/filter-parser/v2 v2.2.1/go.mod h1:P8WG12+x8opr+QHsOqOXYl0dJ3ja2g+SuidZJWD0uPQ=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
||||
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
|
||||
github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY=
|
||||
github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
|
||||
github.com/sigstore/rekor v1.4.3 h1:2+aw4Gbgumv8vYM/QVg6b+hvr4x4Cukur8stJrVPKU0=
|
||||
github.com/sigstore/rekor v1.4.3/go.mod h1:o0zgY087Q21YwohVvGwV9vK1/tliat5mfnPiVI3i75o=
|
||||
github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo=
|
||||
github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU=
|
||||
github.com/sigstore/sigstore v1.10.0 h1:lQrmdzqlR8p9SCfWIpFoGUqdXEzJSZT2X+lTXOMPaQI=
|
||||
github.com/sigstore/sigstore v1.10.0/go.mod h1:Ygq+L/y9Bm3YnjpJTlQrOk/gXyrjkpn3/AEJpmk1n9Y=
|
||||
github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg=
|
||||
github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.0 h1:UOHpiyezCj5RuixgIvCV3QyuxIGQT+N6nGZEXA7OTTY=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.0/go.mod h1:U0CZmA2psabDa8DdiV7yXab0AHODzfKqvD2isH7Hrvw=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.0 h1:fq4+8Y4YadxeF8mzhoMRPZ1mVvDYXmI3BfS0vlkPT7M=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.0/go.mod h1:u05nqPWY05lmcdHhv2lPaWTH3FGUhJzO7iW2hbboK3Q=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0 h1:iUEf5MZYOuXGnXxdF/WrarJrk0DTVHqeIOjYdtpVXtc=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0/go.mod h1:i6vg5JfEQix46R1rhQlrKmUtJoeH91drltyYOJEk1T4=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.0 h1:dUvPv/MP23ZPIXZUW45kvCIgC0ZRfYxEof57AB6bAtU=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.0/go.mod h1:fR/gDdPvJWGWL70/NgBBIL1O0/3Wma6JHs3tSSYg3s4=
|
||||
github.com/sigstore/timestamp-authority/v2 v2.0.3 h1:sRyYNtdED/ttLCMdaYnwpf0zre1A9chvjTnCmWWxN8Y=
|
||||
github.com/sigstore/timestamp-authority/v2 v2.0.3/go.mod h1:mDaHxkt3HmZYoIlwYj4QWo0RUr7VjYU52aVO5f5Qb3I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -361,13 +507,15 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
|
||||
github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug=
|
||||
github.com/theupdateframework/go-tuf/v2 v2.3.0 h1:gt3X8xT8qu/HT4w+n1jgv+p7koi5ad8XEkLXXZqG9AA=
|
||||
github.com/theupdateframework/go-tuf/v2 v2.3.0/go.mod h1:xW8yNvgXRncmovMLvBxKwrKpsOwJZu/8x+aB0KtFcdw=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -379,6 +527,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI=
|
||||
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis=
|
||||
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0=
|
||||
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw=
|
||||
github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0 h1:6nAX1aRGnkg2SEUMwO5toB2tQkP0Jd6cbmZ/K5Le1V0=
|
||||
github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0/go.mod h1:HOC5NWW1wBI2Vke1FGcRBvDATkEYE7AUDiYbXqi2sBw=
|
||||
github.com/tink-crypto/tink-go/v2 v2.5.0 h1:B8KLF6AofxdBIE4UJIaFbmoj5/1ehEtt7/MmzfI4Zpw=
|
||||
github.com/tink-crypto/tink-go/v2 v2.5.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
|
||||
github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw=
|
||||
github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI=
|
||||
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
|
||||
github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||
@@ -387,14 +549,14 @@ github.com/vikstrous/dataloadgen v0.0.10 h1:x07XAeEjIWXohvcjRvE72KY8pV5A3sTbKEFm
|
||||
github.com/vikstrous/dataloadgen v0.0.10/go.mod h1:8vuQVpBH0ODbMKAPUdCAPcOGezoTIhgAjgex51t4vbg=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
|
||||
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc=
|
||||
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg=
|
||||
go.gearno.de/kit v0.10.0 h1:hWQrGdQog5mJQTwNYOnMQDxwPnAZectedP1Xj3NvIH4=
|
||||
@@ -403,8 +565,12 @@ go.gearno.de/x/panicf v0.1.1 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ=
|
||||
go.gearno.de/x/panicf v0.1.1/go.mod h1:VnB8oF0UefMZcYeD4v+Wk4U5Z1uza7PHLlhT2CbNEbU=
|
||||
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c h1:rIVWwnNxHYu9aZhHkptXlNYTBJbY4ccaIAYjztVeaDc=
|
||||
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
@@ -425,49 +591,46 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619 h1:LHOdoF7kYRXFtSP97eWpF1dIf0dBLrunRLOeU/pXt9c=
|
||||
go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619/go.mod h1:HunWQGqLdMocExJh4tWaX7p+uRZ9GlKvBvOXHaFW6vM=
|
||||
go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA=
|
||||
go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
@@ -485,14 +648,10 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -504,5 +663,9 @@ gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
|
||||
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
583
pkg/deviceagent/agent.go
Normal file
583
pkg/deviceagent/agent.go
Normal file
@@ -0,0 +1,583 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/deviceagent/checks"
|
||||
"go.probo.inc/probo/pkg/deviceagent/update"
|
||||
)
|
||||
|
||||
const (
|
||||
hostInfoRefreshInterval = 6 * time.Hour
|
||||
perCheckTimeout = 15 * time.Second
|
||||
|
||||
pendingFlushBackoffMin = 15 * time.Second
|
||||
pendingFlushBackoffMax = 30 * time.Minute
|
||||
|
||||
updateCheckTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// ErrRestartRequired is returned by Agent.Run after a successful
|
||||
// in-place upgrade of the agent binary. Callers should exit cleanly
|
||||
// so the OS service supervisor relaunches the new binary.
|
||||
var ErrRestartRequired = errors.New("agent: restart required after self-update")
|
||||
|
||||
// Agent runs enrollment, heartbeat, and posture sync loops.
|
||||
type Agent struct {
|
||||
Dir string
|
||||
Version string
|
||||
UserAgent string
|
||||
Logger *log.Logger
|
||||
|
||||
// Updater performs binary self-update. When nil, auto-update is
|
||||
// disabled (e.g. dev builds, --no-auto-update at install time).
|
||||
Updater *update.Updater
|
||||
|
||||
cfg *Config
|
||||
client *Client
|
||||
revoked bool
|
||||
|
||||
collectHostInfo func() HostInfo
|
||||
hostInfo HostInfo
|
||||
hostInfoCollectedAt time.Time
|
||||
|
||||
now func() time.Time
|
||||
randInt63n func(int64) int64
|
||||
|
||||
pendingFlushBackoff time.Duration
|
||||
pendingFlushRetryAt time.Time
|
||||
}
|
||||
|
||||
// New creates an agent instance.
|
||||
func New(dir, version string, logger *log.Logger) *Agent {
|
||||
if logger == nil {
|
||||
logger = log.NewLogger(log.WithName("device-agent"))
|
||||
}
|
||||
return &Agent{
|
||||
Dir: dir,
|
||||
Version: version,
|
||||
UserAgent: fmt.Sprintf("probo-agent/%s", version),
|
||||
Logger: logger,
|
||||
collectHostInfo: func() HostInfo {
|
||||
return CollectHostInfo()
|
||||
},
|
||||
now: time.Now,
|
||||
randInt63n: func(n int64) int64 {
|
||||
return rand.Int63n(n)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnrollNewDevice enrolls and persists local config and key state.
|
||||
func (a *Agent) EnrollNewDevice(
|
||||
ctx context.Context,
|
||||
serverURL, enrollmentToken string,
|
||||
) (*EnrollResponse, error) {
|
||||
if serverURL == "" {
|
||||
return nil, errors.New("server URL is required")
|
||||
}
|
||||
if enrollmentToken == "" {
|
||||
return nil, errors.New("enrollment token is required")
|
||||
}
|
||||
|
||||
host := a.currentHostInfo(time.Now())
|
||||
client := NewClient(serverURL, "", a.UserAgent)
|
||||
|
||||
resp, err := client.Enroll(
|
||||
ctx,
|
||||
EnrollRequest{
|
||||
EnrollmentToken: enrollmentToken,
|
||||
HardwareUUID: host.HardwareUUID,
|
||||
SerialNumber: host.SerialNumber,
|
||||
Hostname: host.Hostname,
|
||||
Platform: host.Platform,
|
||||
OSVersion: host.OSVersion,
|
||||
AgentVersion: a.Version,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot enroll device: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: serverURL,
|
||||
DeviceID: resp.DeviceID,
|
||||
HeartbeatInterval: time.Duration(resp.HeartbeatSeconds) * time.Second,
|
||||
PostureInterval: time.Duration(resp.PostureSeconds) * time.Second,
|
||||
}
|
||||
if err := SaveConfig(a.Dir, cfg); err != nil {
|
||||
return nil, fmt.Errorf("cannot save config: %w", err)
|
||||
}
|
||||
if err := SaveAPIKey(a.Dir, resp.APIKey); err != nil {
|
||||
return nil, fmt.Errorf("cannot save api key: %w", err)
|
||||
}
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.Warn("cannot clear pending posture queue after enrollment", log.Error(err))
|
||||
}
|
||||
|
||||
a.cfg = cfg
|
||||
a.client = NewClient(serverURL, resp.APIKey, a.UserAgent)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// LoadLocalState loads persisted config and API key.
|
||||
func (a *Agent) LoadLocalState() error {
|
||||
cfg, err := LoadConfig(a.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := LoadAPIKey(a.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("config has no server URL")
|
||||
}
|
||||
a.cfg = cfg
|
||||
a.client = NewClient(cfg.ServerURL, key, a.UserAgent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the long-running heartbeat and posture loops. It returns
|
||||
// ErrRestartRequired after a successful self-update so the caller can
|
||||
// exit cleanly and let the service supervisor restart the new binary.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if a.cfg == nil || a.client == nil {
|
||||
if err := a.LoadLocalState(); err != nil {
|
||||
return fmt.Errorf("cannot load agent state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
a.Logger = a.Logger.With(log.String("device_id", a.cfg.DeviceID))
|
||||
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent starting",
|
||||
log.String("server", a.cfg.ServerURL),
|
||||
log.Duration("heartbeat_interval", a.cfg.HeartbeatInterval),
|
||||
log.Duration("posture_interval", a.cfg.PostureInterval),
|
||||
log.Duration("host_info_refresh_interval", hostInfoRefreshInterval),
|
||||
log.Bool("auto_update_enabled", a.autoUpdateEnabled()),
|
||||
log.Duration("update_interval", a.cfg.UpdateInterval),
|
||||
)
|
||||
|
||||
_, _ = a.doHeartbeat(ctx)
|
||||
a.doPostures(ctx)
|
||||
|
||||
heartbeatTicker := time.NewTicker(a.cfg.HeartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
postureTicker := time.NewTicker(a.cfg.PostureInterval)
|
||||
defer postureTicker.Stop()
|
||||
|
||||
updateTicker, updateChan := a.newUpdateTicker()
|
||||
if updateTicker != nil {
|
||||
defer updateTicker.Stop()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.Logger.InfoCtx(ctx, "agent stopping", log.Error(ctx.Err()))
|
||||
return ctx.Err()
|
||||
case <-heartbeatTicker.C:
|
||||
heartbeatIntervalChanged, postureIntervalChanged := a.doHeartbeat(ctx)
|
||||
if heartbeatIntervalChanged {
|
||||
heartbeatTicker.Reset(a.cfg.HeartbeatInterval)
|
||||
}
|
||||
if postureIntervalChanged {
|
||||
postureTicker.Reset(a.cfg.PostureInterval)
|
||||
}
|
||||
case <-postureTicker.C:
|
||||
a.doPostures(ctx)
|
||||
case <-updateChan:
|
||||
if a.tryAutoUpdate(ctx) {
|
||||
return ErrRestartRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// autoUpdateEnabled reports whether the agent should periodically
|
||||
// self-update. Disabled when no Updater is wired in or the operator
|
||||
// flipped UpdatesDisabled in config.
|
||||
func (a *Agent) autoUpdateEnabled() bool {
|
||||
if a.cfg == nil {
|
||||
return false
|
||||
}
|
||||
if a.cfg.UpdatesDisabled {
|
||||
return false
|
||||
}
|
||||
return a.Updater != nil
|
||||
}
|
||||
|
||||
// newUpdateTicker returns the periodic ticker used to drive
|
||||
// auto-update checks. When auto-update is disabled we return a
|
||||
// (nil, nil) channel pair so the select in Run never fires.
|
||||
func (a *Agent) newUpdateTicker() (*time.Ticker, <-chan time.Time) {
|
||||
if !a.autoUpdateEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
t := time.NewTicker(a.cfg.UpdateInterval)
|
||||
return t, t.C
|
||||
}
|
||||
|
||||
// tryAutoUpdate runs one auto-update cycle and returns true when the
|
||||
// agent binary was successfully replaced and the process should
|
||||
// restart.
|
||||
func (a *Agent) tryAutoUpdate(parent context.Context) bool {
|
||||
if !a.autoUpdateEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, updateCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
rel, err := a.Updater.CheckLatest(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, update.ErrNoUpdateAvailable) {
|
||||
a.Logger.DebugCtx(ctx, "no agent update available")
|
||||
return false
|
||||
}
|
||||
a.Logger.WarnCtx(ctx, "agent update check failed", log.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent update available, applying",
|
||||
log.String("from_version", a.Version),
|
||||
log.String("to_version", rel.Version),
|
||||
)
|
||||
|
||||
if err := a.Updater.Apply(ctx, rel); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot apply agent update", log.Error(err), log.String("to_version", rel.Version))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CollectOnce executes checks without pushing results to the server.
|
||||
func (a *Agent) CollectOnce(ctx context.Context) []checks.Result {
|
||||
now := time.Now()
|
||||
results := make([]checks.Result, 0)
|
||||
for _, c := range checks.All() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return results
|
||||
default:
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, perCheckTimeout)
|
||||
r := c.Run(checkCtx)
|
||||
cancel()
|
||||
if r.ObservedAt.IsZero() {
|
||||
r.ObservedAt = now
|
||||
}
|
||||
if r.CheckKey == "" {
|
||||
r.CheckKey = c.Key()
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Unenroll revokes server state best-effort and clears local credentials.
|
||||
func (a *Agent) Unenroll(ctx context.Context) error {
|
||||
if a.cfg == nil || a.client == nil {
|
||||
if err := a.LoadLocalState(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) {
|
||||
if a.revoked {
|
||||
return false, false
|
||||
}
|
||||
|
||||
oldHeartbeatInterval := a.cfg.HeartbeatInterval
|
||||
oldPostureInterval := a.cfg.PostureInterval
|
||||
|
||||
host := a.currentHostInfo(time.Now())
|
||||
resp, err := a.client.Heartbeat(
|
||||
ctx,
|
||||
HeartbeatRequest{
|
||||
AgentVersion: a.Version,
|
||||
Hostname: host.Hostname,
|
||||
OSVersion: host.OSVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err))
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
if resp.HeartbeatSeconds > 0 {
|
||||
next := normalizeHeartbeatInterval(time.Duration(resp.HeartbeatSeconds) * time.Second)
|
||||
if next != a.cfg.HeartbeatInterval {
|
||||
a.cfg.HeartbeatInterval = next
|
||||
}
|
||||
}
|
||||
if resp.PostureSeconds > 0 {
|
||||
next := normalizePostureInterval(time.Duration(resp.PostureSeconds) * time.Second)
|
||||
if next != a.cfg.PostureInterval {
|
||||
a.cfg.PostureInterval = next
|
||||
}
|
||||
}
|
||||
a.flushQueuedPostures(ctx)
|
||||
|
||||
heartbeatChanged := a.cfg.HeartbeatInterval != oldHeartbeatInterval
|
||||
postureChanged := a.cfg.PostureInterval != oldPostureInterval
|
||||
if heartbeatChanged || postureChanged {
|
||||
if err := SaveConfig(a.Dir, a.cfg); err != nil {
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"cannot persist updated agent intervals",
|
||||
log.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return heartbeatChanged, postureChanged
|
||||
}
|
||||
|
||||
func (a *Agent) doPostures(ctx context.Context) {
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
results := a.CollectOnce(ctx)
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
var (
|
||||
passCount int
|
||||
failCount int
|
||||
unknownCount int
|
||||
notApplicableCount int
|
||||
)
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case checks.StatusPass:
|
||||
passCount++
|
||||
case checks.StatusFail:
|
||||
failCount++
|
||||
case checks.StatusUnknown:
|
||||
unknownCount++
|
||||
case checks.StatusNotApplicable:
|
||||
notApplicableCount++
|
||||
}
|
||||
}
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"posture checks completed",
|
||||
log.Int("checks", len(results)),
|
||||
log.Int("pass_count", passCount),
|
||||
log.Int("fail_count", failCount),
|
||||
log.Int("unknown_count", unknownCount),
|
||||
log.Int("not_applicable_count", notApplicableCount),
|
||||
log.Duration("elapsed", time.Since(start)),
|
||||
log.Duration("per_check_timeout", perCheckTimeout),
|
||||
)
|
||||
|
||||
payload := make([]PostureResultPayload, 0, len(results))
|
||||
for _, r := range results {
|
||||
payload = append(
|
||||
payload,
|
||||
PostureResultPayload{
|
||||
CheckKey: r.CheckKey,
|
||||
Status: string(r.Status),
|
||||
Evidence: checks.EvidenceJSON(r.Evidence),
|
||||
ObservedAt: r.ObservedAt,
|
||||
},
|
||||
)
|
||||
}
|
||||
a.flushQueuedPostures(ctx)
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
if err := a.client.PushPostures(ctx, payload); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "posture push failed", log.Error(err))
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
return
|
||||
}
|
||||
dropped, enqueueErr := enqueuePendingPostureBatch(a.Dir, payload, a.currentTime())
|
||||
if enqueueErr != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot queue posture batch after failed push", log.Error(enqueueErr))
|
||||
return
|
||||
}
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"queued posture batch for retry",
|
||||
log.Int("queued_results", len(payload)),
|
||||
log.Int("dropped_old_batches", dropped),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) flushQueuedPostures(ctx context.Context) {
|
||||
if a.revoked || a.client == nil {
|
||||
return
|
||||
}
|
||||
now := a.currentTime()
|
||||
if !a.pendingFlushRetryAt.IsZero() && now.Before(a.pendingFlushRetryAt) {
|
||||
return
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(a.Dir)
|
||||
if err != nil {
|
||||
a.Logger.WarnCtx(ctx, "cannot load pending posture batches", log.Error(err))
|
||||
return
|
||||
}
|
||||
if len(batches) == 0 {
|
||||
a.resetPendingFlushRetry()
|
||||
return
|
||||
}
|
||||
|
||||
for i, batch := range batches {
|
||||
if err := a.client.PushPostures(ctx, batch.Results); err != nil {
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
return
|
||||
}
|
||||
if saveErr := savePendingPostureBatches(a.Dir, batches[i:]); saveErr != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot persist pending posture batches", log.Error(saveErr))
|
||||
}
|
||||
retryIn := a.schedulePendingFlushRetry(now)
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"cannot flush pending posture batch",
|
||||
log.Error(err),
|
||||
log.Int("remaining_batches", len(batches)-i),
|
||||
log.Duration("retry_in", retryIn),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot clear pending posture batches", log.Error(err))
|
||||
return
|
||||
}
|
||||
a.resetPendingFlushRetry()
|
||||
a.Logger.InfoCtx(ctx, "flushed pending posture batches", log.Int("batches", len(batches)))
|
||||
}
|
||||
|
||||
func (a *Agent) currentHostInfo(now time.Time) HostInfo {
|
||||
if a.hostInfoCollectedAt.IsZero() || now.Sub(a.hostInfoCollectedAt) >= hostInfoRefreshInterval {
|
||||
collector := a.collectHostInfo
|
||||
if collector == nil {
|
||||
collector = CollectHostInfo
|
||||
}
|
||||
a.hostInfo = collector()
|
||||
a.hostInfoCollectedAt = now
|
||||
}
|
||||
return a.hostInfo
|
||||
}
|
||||
|
||||
func (a *Agent) currentTime() time.Time {
|
||||
if a.now != nil {
|
||||
return a.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (a *Agent) randomInt63n(n int64) int64 {
|
||||
if n <= 1 {
|
||||
return 0
|
||||
}
|
||||
if a.randInt63n != nil {
|
||||
return a.randInt63n(n)
|
||||
}
|
||||
return rand.Int63n(n)
|
||||
}
|
||||
|
||||
func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration {
|
||||
nextBase := a.pendingFlushBackoff
|
||||
if nextBase <= 0 {
|
||||
nextBase = pendingFlushBackoffMin
|
||||
} else {
|
||||
nextBase *= 2
|
||||
if nextBase > pendingFlushBackoffMax {
|
||||
nextBase = pendingFlushBackoffMax
|
||||
}
|
||||
}
|
||||
a.pendingFlushBackoff = nextBase
|
||||
|
||||
jitterRange := nextBase / 5
|
||||
jitter := time.Duration(0)
|
||||
if jitterRange > 0 {
|
||||
jitter = time.Duration(a.randomInt63n(int64(jitterRange)*2+1)) - jitterRange
|
||||
}
|
||||
|
||||
retryIn := nextBase + jitter
|
||||
if retryIn < time.Second {
|
||||
retryIn = time.Second
|
||||
}
|
||||
a.pendingFlushRetryAt = now.Add(retryIn)
|
||||
return retryIn
|
||||
}
|
||||
|
||||
func (a *Agent) resetPendingFlushRetry() {
|
||||
a.pendingFlushBackoff = 0
|
||||
a.pendingFlushRetryAt = time.Time{}
|
||||
}
|
||||
|
||||
// handleUnauthorized wipes local auth state after a 401 response.
|
||||
func (a *Agent) handleUnauthorized() {
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
a.revoked = true
|
||||
if a.client != nil {
|
||||
a.client.APIKey = ""
|
||||
}
|
||||
|
||||
a.Logger.Warn("agent API returned 401, wiping local key and requiring re-enrollment")
|
||||
if err := DeleteAPIKey(a.Dir); err != nil {
|
||||
a.Logger.Error("cannot delete local key after 401", log.Error(err))
|
||||
}
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err))
|
||||
}
|
||||
a.resetPendingFlushRetry()
|
||||
}
|
||||
71
pkg/deviceagent/agent_test.go
Normal file
71
pkg/deviceagent/agent_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgent_currentHostInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"reuses cached host info before refresh interval",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
count := 0
|
||||
a := &Agent{
|
||||
collectHostInfo: func() HostInfo {
|
||||
count++
|
||||
return HostInfo{Hostname: fmt.Sprintf("host-%d", count)}
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Unix(1_000, 0)
|
||||
first := a.currentHostInfo(now)
|
||||
second := a.currentHostInfo(now.Add(2 * time.Hour))
|
||||
|
||||
assert.Equal(t, 1, count)
|
||||
assert.Equal(t, first, second)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"refreshes host info when cache expires",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
count := 0
|
||||
a := &Agent{
|
||||
collectHostInfo: func() HostInfo {
|
||||
count++
|
||||
return HostInfo{Hostname: fmt.Sprintf("host-%d", count)}
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Unix(2_000, 0)
|
||||
first := a.currentHostInfo(now)
|
||||
second := a.currentHostInfo(now.Add(hostInfoRefreshInterval + time.Minute))
|
||||
|
||||
assert.Equal(t, 2, count)
|
||||
assert.NotEqual(t, first, second)
|
||||
},
|
||||
)
|
||||
}
|
||||
34
pkg/deviceagent/checks/check.go
Normal file
34
pkg/deviceagent/checks/check.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is the outcome of one posture check on the current host.
|
||||
type Result struct {
|
||||
CheckKey string
|
||||
Status Status
|
||||
Evidence map[string]any
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
// Check runs a single posture check.
|
||||
type Check interface {
|
||||
Key() string
|
||||
Run(ctx context.Context) Result
|
||||
}
|
||||
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, darwinDiskEncryption)
|
||||
Register(KeyScreenLock, darwinScreenLock)
|
||||
Register(KeyFirewallEnabled, darwinFirewall)
|
||||
Register(KeyTimeSync, darwinTimeSync)
|
||||
Register(KeyOSVersion, darwinOSVersion)
|
||||
Register(KeyAutoUpdate, darwinAutoUpdate)
|
||||
Register(KeyPasswordPolicy, darwinPasswordPolicy)
|
||||
Register(KeyRemoteLogin, darwinRemoteLogin)
|
||||
Register(KeyMalwareProtection, darwinMalwareProtection)
|
||||
}
|
||||
|
||||
func darwinDiskEncryption(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "fdesetup", "status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("sysadminctl") {
|
||||
status := RunCommand(ctx, "sysadminctl", "-screenLock", "status", "-password", "-")
|
||||
rawCombined := strings.TrimSpace(status.Stdout + "\n" + status.Stderr)
|
||||
ev := map[string]any{
|
||||
"backend": "sysadminctl",
|
||||
"raw": rawCombined,
|
||||
"raw_stdout": status.Stdout,
|
||||
"raw_stderr": status.Stderr,
|
||||
}
|
||||
mode, seconds, ok := darwinScreenLockMode(rawCombined)
|
||||
if ok {
|
||||
ev["mode"] = mode
|
||||
if mode == "seconds" && seconds >= 0 {
|
||||
ev["seconds"] = seconds
|
||||
}
|
||||
if mode == "immediate" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if status.Err != nil {
|
||||
ev["error"] = status.Err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword")
|
||||
ev := map[string]any{}
|
||||
if askSource != "" {
|
||||
ev["source"] = askSource
|
||||
}
|
||||
if ask.Err != nil {
|
||||
if darwinDefaultsMissing(ask) {
|
||||
ev["ask_for_password"] = "0"
|
||||
ev["note"] = "askForPassword is unset or unavailable"
|
||||
return fail(ev)
|
||||
}
|
||||
ev["error"] = ask.Err.Error()
|
||||
ev["stderr"] = ask.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
enabled := strings.TrimSpace(ask.Stdout) == "1"
|
||||
|
||||
delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay")
|
||||
ev["ask_for_password"] = ask.Stdout
|
||||
if delayCmd.Err == nil {
|
||||
ev["ask_for_password_delay"] = delayCmd.Stdout
|
||||
if delaySource != "" && delaySource != askSource {
|
||||
ev["delay_source"] = delaySource
|
||||
}
|
||||
}
|
||||
if enabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLockMode(raw string) (string, int, bool) {
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, "immediate") {
|
||||
return "immediate", 0, true
|
||||
}
|
||||
if strings.Contains(lower, "off") {
|
||||
return "off", -1, true
|
||||
}
|
||||
if idx := strings.Index(lower, "seconds"); idx >= 0 {
|
||||
prefix := strings.Fields(lower[:idx])
|
||||
if len(prefix) == 0 {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
n, err := strconv.Atoi(prefix[len(prefix)-1])
|
||||
if err != nil {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
return "seconds", n, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func darwinFirewall(ctx context.Context) Result {
|
||||
out := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.alf",
|
||||
"globalstate",
|
||||
)
|
||||
if out.Err == nil {
|
||||
state := strings.TrimSpace(out.Stdout)
|
||||
ev := map[string]any{"backend": "defaults", "global_state": state}
|
||||
if state == "1" || state == "2" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
ev := map[string]any{
|
||||
"backend": "socketfilterfw",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(out.Err),
|
||||
"defaults_stderr": out.Stderr,
|
||||
}
|
||||
if fallback.Err != nil {
|
||||
ev["error"] = fallback.Err.Error()
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
if darwinStateIndicatesEnabled(fallback.Stdout) {
|
||||
return pass(ev)
|
||||
}
|
||||
if darwinStateIndicatesDisabled(fallback.Stdout) {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// darwinReadScreenSaverDefault prefers console-user settings when running as root.
|
||||
func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, string) {
|
||||
consoleUser := darwinConsoleUser(ctx)
|
||||
if os.Geteuid() == 0 && consoleUser != "" {
|
||||
var consoleMissing CmdResult
|
||||
consoleMissingSource := ""
|
||||
|
||||
if CommandExists("sudo") {
|
||||
consoleUserCurrentHost := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"-currentHost",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserCurrentHost.Err == nil {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserCurrentHost) {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserCurrentHost
|
||||
consoleMissingSource = "console_user_current_host:" + consoleUser
|
||||
}
|
||||
|
||||
consoleUserDomain := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserDomain.Err == nil {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserDomain) {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserDomain
|
||||
consoleMissingSource = "console_user:" + consoleUser
|
||||
}
|
||||
}
|
||||
|
||||
plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist"
|
||||
consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key)
|
||||
if consoleUserOut.Err == nil {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserOut) {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserOut
|
||||
consoleMissingSource = "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource != "" {
|
||||
return consoleMissing, consoleMissingSource
|
||||
}
|
||||
}
|
||||
|
||||
currentHost := RunCommand(ctx, "defaults", "-currentHost", "read", "com.apple.screensaver", key)
|
||||
if currentHost.Err == nil {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
|
||||
currentUser := RunCommand(ctx, "defaults", "read", "com.apple.screensaver", key)
|
||||
if currentUser.Err == nil {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
if !darwinDefaultsMissing(currentUser) {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
if !darwinDefaultsMissing(currentHost) {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
func darwinDefaultsMissing(out CmdResult) bool {
|
||||
lower := strings.ToLower(out.Stderr + "\n" + out.Stdout)
|
||||
return strings.Contains(lower, "does not exist") ||
|
||||
strings.Contains(lower, "could not find") ||
|
||||
strings.Contains(lower, "does not exist in domain")
|
||||
}
|
||||
|
||||
func darwinConsoleUser(ctx context.Context) string {
|
||||
if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" {
|
||||
return sudoUser
|
||||
}
|
||||
out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console")
|
||||
if out.Err != nil {
|
||||
return ""
|
||||
}
|
||||
user := strings.TrimSpace(out.Stdout)
|
||||
if user == "" || user == "root" || user == "loginwindow" {
|
||||
return ""
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func darwinStateIndicatesEnabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "enabled") ||
|
||||
strings.Contains(lower, "state = 1") ||
|
||||
strings.Contains(lower, "state = 2")
|
||||
}
|
||||
|
||||
func darwinStateIndicatesDisabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "disabled") || strings.Contains(lower, "state = 0")
|
||||
}
|
||||
|
||||
func darwinTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getusingnetworktime")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "sw_vers", "-productVersion")
|
||||
if out.Err != nil || out.Stdout == "" {
|
||||
return unknown(map[string]any{"error": "sw_vers failed"})
|
||||
}
|
||||
build := RunCommand(ctx, "sw_vers", "-buildVersion")
|
||||
ev := map[string]any{
|
||||
"product_version": out.Stdout,
|
||||
"build_version": build.Stdout,
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinAutoUpdate(ctx context.Context) Result {
|
||||
primary := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.SoftwareUpdate",
|
||||
"AutomaticCheckEnabled",
|
||||
)
|
||||
if primary.Err == nil {
|
||||
ev := map[string]any{
|
||||
"backend": "defaults",
|
||||
"automatic_check_enabled": primary.Stdout,
|
||||
}
|
||||
if strings.TrimSpace(primary.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "softwareupdate", "--schedule")
|
||||
ev := map[string]any{
|
||||
"backend": "softwareupdate",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(primary.Err),
|
||||
"defaults_stderr": primary.Stderr,
|
||||
}
|
||||
if fallback.Err != nil ||
|
||||
needsAdmin(fallback.Stdout) ||
|
||||
needsAdmin(fallback.Stderr) {
|
||||
ev["error"] = errString(fallback.Err)
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(fallback.Stdout)
|
||||
switch {
|
||||
case strings.Contains(lower, "is turned on"),
|
||||
strings.Contains(lower, "automatic check is on"):
|
||||
return pass(ev)
|
||||
case strings.Contains(lower, "is turned off"),
|
||||
strings.Contains(lower, "automatic check is off"):
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func darwinPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "pwpolicy", "-getaccountpolicies")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(lower, "no account policies") || lower == "" {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getremotelogin")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
off := strings.Contains(strings.ToLower(out.Stdout), "off")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if off {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []string{
|
||||
"/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
"/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
ev := map[string]any{"engine": "XProtect", "plist": path}
|
||||
version := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
strings.TrimSuffix(path, ".plist"),
|
||||
"Version",
|
||||
)
|
||||
if version.Err == nil {
|
||||
ev["version"] = version.Stdout
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(
|
||||
map[string]any{
|
||||
"engine": "XProtect",
|
||||
"note": "XProtect.meta.plist not found in expected locations",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// needsAdmin checks systemsetup's stdout for privilege errors.
|
||||
func needsAdmin(stdout string) bool {
|
||||
return strings.Contains(strings.ToLower(stdout), "administrator access")
|
||||
}
|
||||
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, freebsdDiskEncryption)
|
||||
Register(KeyScreenLock, freebsdScreenLock)
|
||||
Register(KeyFirewallEnabled, freebsdFirewall)
|
||||
Register(KeyTimeSync, freebsdTimeSync)
|
||||
Register(KeyOSVersion, freebsdOSVersion)
|
||||
Register(KeyAutoUpdate, freebsdAutoUpdate)
|
||||
Register(KeyPasswordPolicy, freebsdPasswordPolicy)
|
||||
Register(KeyRemoteLogin, freebsdRemoteLogin)
|
||||
Register(KeyMalwareProtection, freebsdMalwareProtection)
|
||||
}
|
||||
|
||||
func freebsdDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("geli") {
|
||||
return unknown(map[string]any{"note": "geli command not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "geli", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "ACTIVE") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("xscreensaver-command") {
|
||||
out := RunCommand(ctx, "xscreensaver-command", "-version")
|
||||
if out.Err == nil {
|
||||
return pass(map[string]any{"raw": out.Stdout})
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD does not have a unified screen lock policy",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdFirewall(ctx context.Context) Result {
|
||||
if !CommandExists("pfctl") {
|
||||
return unknown(map[string]any{"note": "pfctl not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "pfctl", "-si")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "Status: Enabled") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "ntpd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return fail(ev)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "uname", "-r")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
return pass(map[string]any{"release": out.Stdout})
|
||||
}
|
||||
|
||||
func freebsdAutoUpdate(ctx context.Context) Result {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD relies on operator-driven freebsd-update",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.conf")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
hasPolicy := strings.Contains(body, "minpasswordlen=") ||
|
||||
strings.Contains(body, "passwordtime=")
|
||||
ev := map[string]any{
|
||||
"login_conf_snippet": truncate(body, 400),
|
||||
}
|
||||
if hasPolicy {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdMalwareProtection(ctx context.Context) Result {
|
||||
if !CommandExists("clamd") && !CommandExists("clamdscan") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "clamav not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "service", "clamav_clamd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "sshd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
399
pkg/deviceagent/checks/checks_linux.go
Normal file
399
pkg/deviceagent/checks/checks_linux.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, linuxDiskEncryption)
|
||||
Register(KeyScreenLock, linuxScreenLock)
|
||||
Register(KeyFirewallEnabled, linuxFirewall)
|
||||
Register(KeyTimeSync, linuxTimeSync)
|
||||
Register(KeyOSVersion, linuxOSVersion)
|
||||
Register(KeyAutoUpdate, linuxAutoUpdate)
|
||||
Register(KeyPasswordPolicy, linuxPasswordPolicy)
|
||||
Register(KeyRemoteLogin, linuxRemoteLogin)
|
||||
Register(KeyMalwareProtection, linuxMalwareProtection)
|
||||
}
|
||||
|
||||
func linuxDiskEncryption(ctx context.Context) Result {
|
||||
ev := map[string]any{}
|
||||
|
||||
if data, err := os.ReadFile("/etc/crypttab"); err == nil {
|
||||
body := strings.TrimSpace(string(data))
|
||||
ev["crypttab_present"] = true
|
||||
ev["crypttab_lines"] = nonCommentLines(body)
|
||||
if len(nonCommentLines(body)) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
} else {
|
||||
ev["crypttab_present"] = false
|
||||
}
|
||||
|
||||
lsblk := RunCommand(ctx, "lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-r")
|
||||
if lsblk.Err == nil {
|
||||
ev["lsblk"] = truncate(lsblk.Stdout, 800)
|
||||
lines := strings.Split(lsblk.Stdout, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "crypt" {
|
||||
return pass(ev)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ev["lsblk_error"] = lsblk.Err.Error()
|
||||
}
|
||||
|
||||
if lsblk.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxScreenLock(ctx context.Context) Result {
|
||||
if !CommandExists("gsettings") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "gsettings not installed (likely headless host)",
|
||||
},
|
||||
)
|
||||
}
|
||||
idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled")
|
||||
if idle.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": idle.Err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.TrimSpace(idle.Stdout) == "true"
|
||||
ev := map[string]any{"lock_enabled": idle.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxFirewall(ctx context.Context) Result {
|
||||
if CommandExists("ufw") {
|
||||
out := RunCommand(ctx, "ufw", "status")
|
||||
if out.Err == nil {
|
||||
active := strings.Contains(strings.ToLower(out.Stdout), "status: active")
|
||||
ev := map[string]any{"backend": "ufw", "raw": out.Stdout}
|
||||
if active {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
if CommandExists("firewall-cmd") {
|
||||
out := RunCommand(ctx, "firewall-cmd", "--state")
|
||||
ev := map[string]any{"backend": "firewalld", "raw": out.Stdout}
|
||||
if out.Err == nil && strings.Contains(strings.ToLower(out.Stdout), "running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("nft") {
|
||||
out := RunCommand(ctx, "nft", "list", "ruleset")
|
||||
ev := map[string]any{
|
||||
"backend": "nftables",
|
||||
"rules_excerpt": truncate(out.Stdout, 400),
|
||||
}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "chain ") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("iptables") {
|
||||
out := RunCommand(ctx, "iptables", "-S", "INPUT")
|
||||
ev := map[string]any{"backend": "iptables"}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
policy, rules := parseIptablesInput(out.Stdout)
|
||||
ev["input_policy"] = policy
|
||||
ev["input_rules"] = rules
|
||||
if policy == "DROP" || policy == "REJECT" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
if rules == 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
// ACCEPT policy with some rules means the operator is filtering,
|
||||
// but we cannot tell from -S whether the rules are restrictive
|
||||
// or permissive without modelling the chain.
|
||||
return unknown(ev)
|
||||
}
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "no known firewall tool found",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// parseIptablesInput extracts the INPUT chain policy and rule count from
|
||||
// `iptables -S INPUT` output.
|
||||
func parseIptablesInput(s string) (string, int) {
|
||||
var (
|
||||
policy string
|
||||
rules int
|
||||
)
|
||||
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-P INPUT"):
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
policy = strings.ToUpper(fields[2])
|
||||
}
|
||||
case strings.HasPrefix(line, "-A INPUT"):
|
||||
rules++
|
||||
}
|
||||
}
|
||||
|
||||
return policy, rules
|
||||
}
|
||||
|
||||
func linuxTimeSync(ctx context.Context) Result {
|
||||
if !CommandExists("timedatectl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "timedatectl not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "timedatectl", "show")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(out.Stdout, "NTPSynchronized=yes") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxOSVersion(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"pretty_name": kvLookup(body, "PRETTY_NAME"),
|
||||
"version_id": kvLookup(body, "VERSION_ID"),
|
||||
"id": kvLookup(body, "ID"),
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func linuxAutoUpdate(ctx context.Context) Result {
|
||||
if _, err := os.Stat("/etc/apt/apt.conf.d/20auto-upgrades"); err == nil {
|
||||
data, _ := os.ReadFile("/etc/apt/apt.conf.d/20auto-upgrades")
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"backend": "unattended-upgrades",
|
||||
"raw": body,
|
||||
}
|
||||
if strings.Contains(body, `"1"`) {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if CommandExists("systemctl") {
|
||||
out := RunCommand(ctx, "systemctl", "is-enabled", "dnf-automatic.timer")
|
||||
if out.Err == nil {
|
||||
ev := map[string]any{"backend": "dnf-automatic", "state": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "enabled" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "no known auto-update mechanism",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func linuxPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.defs")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
minLen := loginDefsLookup(body, "PASS_MIN_LEN")
|
||||
maxDays := loginDefsLookup(body, "PASS_MAX_DAYS")
|
||||
ev := map[string]any{
|
||||
"pass_min_len": minLen,
|
||||
"pass_max_days": maxDays,
|
||||
}
|
||||
if minLen == "" {
|
||||
ev["parse_error"] = "PASS_MIN_LEN not set"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
minLenValue, err := strconv.Atoi(minLen)
|
||||
if err != nil {
|
||||
ev["parse_error"] = "invalid PASS_MIN_LEN value"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if minLenValue >= 8 {
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxRemoteLogin(ctx context.Context) Result {
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(map[string]any{"note": "systemctl unavailable"})
|
||||
}
|
||||
state := RunCommand(ctx, "systemctl", "is-active", "ssh.service")
|
||||
stateAlt := RunCommand(ctx, "systemctl", "is-active", "sshd.service")
|
||||
merged := strings.TrimSpace(state.Stdout)
|
||||
if merged == "" {
|
||||
merged = strings.TrimSpace(stateAlt.Stdout)
|
||||
}
|
||||
ev := map[string]any{"is_active": merged}
|
||||
switch merged {
|
||||
case "active":
|
||||
return fail(ev)
|
||||
case "inactive", "failed":
|
||||
return pass(ev)
|
||||
case "":
|
||||
return notApplicable(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// linuxMalwareProtection tracks AV/EDR agent services, not MAC frameworks.
|
||||
func linuxMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []struct {
|
||||
unit string
|
||||
name string
|
||||
}{
|
||||
{"clamav-daemon.service", "ClamAV"},
|
||||
{"clamd.service", "ClamAV"},
|
||||
{"clamd@scan.service", "ClamAV"},
|
||||
{"falcon-sensor.service", "CrowdStrike Falcon"},
|
||||
{"sentinelone.service", "SentinelOne"},
|
||||
{"sentineld.service", "SentinelOne"},
|
||||
{"sav-protect.service", "Sophos"},
|
||||
{"sophos-spl.service", "Sophos"},
|
||||
{"esets.service", "ESET"},
|
||||
{"mdatp.service", "Microsoft Defender for Endpoint"},
|
||||
{"wazuh-agent.service", "Wazuh"},
|
||||
{"ossec.service", "OSSEC"},
|
||||
{"elastic-agent.service", "Elastic Agent"},
|
||||
{"osqueryd.service", "osquery"},
|
||||
}
|
||||
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "systemctl not available; cannot enumerate endpoint agents",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var active, installed []string
|
||||
for _, c := range candidates {
|
||||
state := strings.TrimSpace(
|
||||
RunCommand(ctx, "systemctl", "is-active", c.unit).Stdout)
|
||||
switch state {
|
||||
case "active":
|
||||
active = append(active, c.name)
|
||||
case "inactive", "failed", "activating", "deactivating":
|
||||
installed = append(installed, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"active": active,
|
||||
"installed": installed,
|
||||
}
|
||||
if len(active) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
if len(installed) > 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func nonCommentLines(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kvLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line[:eq]) == key {
|
||||
v := strings.TrimSpace(line[eq+1:])
|
||||
v = strings.Trim(v, `"`)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loginDefsLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == key {
|
||||
return fields[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
421
pkg/deviceagent/checks/checks_windows.go
Normal file
421
pkg/deviceagent/checks/checks_windows.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, windowsDiskEncryption)
|
||||
Register(KeyScreenLock, windowsScreenLock)
|
||||
Register(KeyFirewallEnabled, windowsFirewall)
|
||||
Register(KeyTimeSync, windowsTimeSync)
|
||||
Register(KeyOSVersion, windowsOSVersion)
|
||||
Register(KeyAutoUpdate, windowsAutoUpdate)
|
||||
Register(KeyPasswordPolicy, windowsPasswordPolicy)
|
||||
Register(KeyRemoteLogin, windowsRemoteLogin)
|
||||
Register(KeyMalwareProtection, windowsMalwareProtection)
|
||||
}
|
||||
|
||||
const psNoProfile = "-NoProfile"
|
||||
|
||||
func powershell(ctx context.Context, script string) CmdResult {
|
||||
return RunCommand(ctx, "powershell.exe", psNoProfile, "-Command", script)
|
||||
}
|
||||
|
||||
func windowsDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("manage-bde.exe") && !CommandExists("manage-bde") {
|
||||
return unknown(map[string]any{"note": "manage-bde not found"})
|
||||
}
|
||||
|
||||
out := RunCommand(ctx, "manage-bde", "-status")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 600)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "percentage encrypted: 100") ||
|
||||
strings.Contains(lower, "fully encrypted") ||
|
||||
strings.Contains(lower, "protection on") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsScreenLock(ctx context.Context) Result {
|
||||
// HKCU resolves to the SYSTEM hive when the agent runs as LocalSystem,
|
||||
// so we first look for a machine-wide policy and then enumerate every
|
||||
// loaded interactive user hive under HKU.
|
||||
machine := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop' `+
|
||||
`-ErrorAction SilentlyContinue).ScreenSaverIsSecure`,
|
||||
)
|
||||
if machine.Err == nil {
|
||||
v := strings.TrimSpace(machine.Stdout)
|
||||
if v != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "machine_policy",
|
||||
"screen_saver_is_secure": v,
|
||||
}
|
||||
if v == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
|
||||
users := powershell(
|
||||
ctx,
|
||||
`Get-ChildItem 'Registry::HKEY_USERS' | `+
|
||||
`Where-Object { $_.PSChildName -match '^S-1-5-21-' } | `+
|
||||
`ForEach-Object { `+
|
||||
` $path = "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop"; `+
|
||||
` $key = Get-ItemProperty $path -ErrorAction SilentlyContinue; `+
|
||||
` "$($_.PSChildName)=$($key.ScreenSaverIsSecure)" `+
|
||||
`}`,
|
||||
)
|
||||
if users.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"error": users.Err.Error(),
|
||||
"stderr": users.Stderr,
|
||||
"machine_policy_error": errString(machine.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"raw": truncate(users.Stdout, 400),
|
||||
}
|
||||
users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout)
|
||||
ev["users"] = users_
|
||||
if len(users_) == 0 {
|
||||
ev["note"] = "no interactive user hives loaded"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if anyEnabled && !anyDisabled {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsUserScreenLock parses one "SID=<value>" line per user from
|
||||
// the registry enumeration and reports whether each user has screen
|
||||
// saver locking enabled.
|
||||
func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) {
|
||||
users := map[string]string{}
|
||||
var anyEnabled, anyDisabled bool
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(line, "=")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sid := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
users[sid] = value
|
||||
switch value {
|
||||
case "1":
|
||||
anyEnabled = true
|
||||
default:
|
||||
anyDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
return users, anyDisabled, anyEnabled
|
||||
}
|
||||
|
||||
func windowsFirewall(ctx context.Context) Result {
|
||||
primary := powershell(
|
||||
ctx,
|
||||
`(Get-NetFirewallProfile -PolicyStore ActiveStore | `+
|
||||
`Sort-Object Name | `+
|
||||
`ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join ";"`,
|
||||
)
|
||||
if primary.Err == nil && strings.TrimSpace(primary.Stdout) != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "Get-NetFirewallProfile",
|
||||
"raw": primary.Stdout,
|
||||
}
|
||||
profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout)
|
||||
ev["profiles"] = profiles
|
||||
if allEnabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "netsh", "advfirewall", "show", "allprofiles", "state")
|
||||
if fallback.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": errString(fallback.Err),
|
||||
"stderr": fallback.Stderr,
|
||||
"powershell_error": errString(primary.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
ev := map[string]any{
|
||||
"backend": "netsh",
|
||||
"raw": truncate(fallback.Stdout, 600),
|
||||
}
|
||||
stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout)
|
||||
ev["state_lines"] = stateLines
|
||||
if len(stateLines) > 0 && !anyOff {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsFirewallProfiles parses "Domain=True;Private=True;Public=True"
|
||||
// from Get-NetFirewallProfile output, returning per-profile state and
|
||||
// whether every profile is enabled.
|
||||
func parseWindowsFirewallProfiles(s string) (map[string]string, bool) {
|
||||
profiles := map[string]string{}
|
||||
allEnabled := true
|
||||
any := false
|
||||
for _, profile := range strings.Split(s, ";") {
|
||||
parts := strings.SplitN(strings.TrimSpace(profile), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
profiles[name] = value
|
||||
any = true
|
||||
if !strings.EqualFold(value, "true") {
|
||||
allEnabled = false
|
||||
}
|
||||
}
|
||||
return profiles, any && allEnabled
|
||||
}
|
||||
|
||||
// parseNetshFirewallStates extracts per-profile "State <ON|OFF>" lines
|
||||
// from `netsh advfirewall show allprofiles state`. It is whitespace- and
|
||||
// case-insensitive.
|
||||
func parseNetshFirewallStates(s string) ([]string, bool) {
|
||||
var states []string
|
||||
anyOff := false
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if !strings.HasPrefix(lower, "state") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(lower)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
value := fields[len(fields)-1]
|
||||
states = append(states, value)
|
||||
if value != "on" {
|
||||
anyOff = true
|
||||
}
|
||||
}
|
||||
|
||||
return states, anyOff
|
||||
}
|
||||
|
||||
func windowsTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "w32tm", "/query", "/status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsOSVersion(ctx context.Context) Result {
|
||||
out := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Version`)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`)
|
||||
return pass(
|
||||
map[string]any{
|
||||
"version": out.Stdout,
|
||||
"caption": caption.Stdout,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func windowsAutoUpdate(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$au = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `+
|
||||
`-ErrorAction SilentlyContinue; `+
|
||||
`"$($au.NoAutoUpdate);$($au.AUOptions)"`,
|
||||
)
|
||||
ev := map[string]any{}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
ev["stderr"] = out.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2)
|
||||
var noAutoUpdate, auOptions string
|
||||
if len(parts) >= 1 {
|
||||
noAutoUpdate = strings.TrimSpace(parts[0])
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
auOptions = strings.TrimSpace(parts[1])
|
||||
}
|
||||
ev["no_auto_update"] = noAutoUpdate
|
||||
ev["au_options"] = auOptions
|
||||
|
||||
// NoAutoUpdate=1 explicitly disables automatic updates via policy.
|
||||
if noAutoUpdate == "1" {
|
||||
return fail(ev)
|
||||
}
|
||||
// AUOptions semantics:
|
||||
// 2 — notify before download (no auto-install)
|
||||
// 3 — auto download, prompt to install
|
||||
// 4 — auto download + auto install (target SOC posture)
|
||||
// 5 — managed by local administrators
|
||||
switch auOptions {
|
||||
case "3", "4", "5":
|
||||
return pass(ev)
|
||||
case "2":
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// No managed policy. The Windows Update service must at least be
|
||||
// running for the OS default of auto-install to take effect.
|
||||
svc := RunCommand(ctx, "sc.exe", "query", "wuauserv")
|
||||
if svc.Err != nil {
|
||||
ev["wuauserv_error"] = svc.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if strings.Contains(svc.Stdout, "RUNNING") {
|
||||
ev["wuauserv"] = "running"
|
||||
return pass(ev)
|
||||
}
|
||||
ev["wuauserv"] = "stopped"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "net", "accounts")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsMalwareProtection(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$s = Get-MpComputerStatus; `+
|
||||
`"$($s.AntivirusEnabled);$($s.RealTimeProtectionEnabled);`+
|
||||
`$($s.AMServiceEnabled);$($s.AntivirusSignatureLastUpdated)"`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
parts := strings.Split(out.Stdout, ";")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if len(parts) < 3 {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True")
|
||||
realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True")
|
||||
serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True")
|
||||
ev["antivirus_enabled"] = antivirusOn
|
||||
ev["real_time_protection"] = realtimeOn
|
||||
ev["am_service_enabled"] = serviceOn
|
||||
if len(parts) >= 4 {
|
||||
ev["signatures_last_updated"] = strings.TrimSpace(parts[3])
|
||||
}
|
||||
|
||||
if antivirusOn && (realtimeOn || serviceOn) {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsRemoteLogin(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server').fDenyTSConnections`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
ev := map[string]any{"fdeny_ts_connections": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
31
pkg/deviceagent/checks/evidence.go
Normal file
31
pkg/deviceagent/checks/evidence.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 checks
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// EvidenceJSON encodes evidence for transport to the agent API.
|
||||
func EvidenceJSON(ev map[string]any) json.RawMessage {
|
||||
if len(ev) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
55
pkg/deviceagent/checks/registry.go
Normal file
55
pkg/deviceagent/checks/registry.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
registryMu sync.Mutex
|
||||
registry []Check
|
||||
)
|
||||
|
||||
// Register adds a check implementation to the process registry.
|
||||
func Register(key string, run func(context.Context) Result) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(
|
||||
registry,
|
||||
funcCheck{
|
||||
key: key,
|
||||
run: run,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// All returns a stable snapshot of registered checks.
|
||||
func All() []Check {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
out := make([]Check, len(registry))
|
||||
copy(out, registry)
|
||||
sort.SliceStable(
|
||||
out,
|
||||
func(i, j int) bool {
|
||||
return out[i].Key() < out[j].Key()
|
||||
},
|
||||
)
|
||||
return out
|
||||
}
|
||||
101
pkg/deviceagent/checks/runcmd.go
Normal file
101
pkg/deviceagent/checks/runcmd.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultCommandTimeout = 5 * time.Second
|
||||
|
||||
var commandExistsCache sync.Map
|
||||
|
||||
// CmdResult captures the basic outcome of an OS subcommand.
|
||||
type CmdResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// RunCommand executes a command and returns trimmed stdout/stderr.
|
||||
func RunCommand(ctx context.Context, name string, args ...string) CmdResult {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, defaultCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
resolved, ok := resolveCommandPath(name)
|
||||
if !ok {
|
||||
return CmdResult{
|
||||
Err: fmt.Errorf("command %q not available at expected absolute path", name),
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, resolved, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return CmdResult{
|
||||
Stdout: strings.TrimSpace(stdout.String()),
|
||||
Stderr: strings.TrimSpace(stderr.String()),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// CommandExists reports whether `cmd` exists at expected absolute path(s).
|
||||
func CommandExists(cmd string) bool {
|
||||
if cached, ok := commandExistsCache.Load(cmd); ok {
|
||||
return cached.(bool)
|
||||
}
|
||||
|
||||
_, exists := resolveCommandPath(cmd)
|
||||
commandExistsCache.Store(cmd, exists)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func resolveCommandPath(cmd string) (string, bool) {
|
||||
if filepath.IsAbs(cmd) {
|
||||
return cmd, isExecutableFile(cmd)
|
||||
}
|
||||
|
||||
for _, candidate := range commandCandidates(cmd) {
|
||||
if isExecutableFile(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
|
||||
return info.Mode().Perm()&0o111 != 0
|
||||
}
|
||||
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 checks
|
||||
|
||||
var darwinCommandPaths = map[string][]string{
|
||||
"defaults": {"/usr/bin/defaults"},
|
||||
"fdesetup": {"/usr/bin/fdesetup"},
|
||||
"pwpolicy": {"/usr/bin/pwpolicy"},
|
||||
"softwareupdate": {"/usr/sbin/softwareupdate"},
|
||||
"stat": {"/usr/bin/stat"},
|
||||
"sudo": {"/usr/bin/sudo"},
|
||||
"sw_vers": {"/usr/bin/sw_vers"},
|
||||
"sysadminctl": {"/usr/sbin/sysadminctl"},
|
||||
"systemsetup": {"/usr/sbin/systemsetup"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return darwinCommandPaths[cmd]
|
||||
}
|
||||
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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 checks
|
||||
|
||||
var freeBSDCommandPaths = map[string][]string{
|
||||
"clamd": {"/usr/local/sbin/clamd", "/usr/sbin/clamd"},
|
||||
"clamdscan": {"/usr/local/bin/clamdscan", "/usr/bin/clamdscan"},
|
||||
"geli": {"/sbin/geli"},
|
||||
"pfctl": {"/sbin/pfctl"},
|
||||
"service": {"/usr/sbin/service"},
|
||||
"uname": {"/usr/bin/uname"},
|
||||
"xscreensaver-command": {"/usr/local/bin/xscreensaver-command"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return freeBSDCommandPaths[cmd]
|
||||
}
|
||||
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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 checks
|
||||
|
||||
var linuxCommandPaths = map[string][]string{
|
||||
"firewall-cmd": {"/usr/bin/firewall-cmd"},
|
||||
"gsettings": {"/usr/bin/gsettings"},
|
||||
"iptables": {"/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables"},
|
||||
"lsblk": {"/usr/bin/lsblk", "/bin/lsblk"},
|
||||
"nft": {"/usr/sbin/nft", "/sbin/nft"},
|
||||
"systemctl": {"/usr/bin/systemctl", "/bin/systemctl"},
|
||||
"timedatectl": {"/usr/bin/timedatectl", "/bin/timedatectl"},
|
||||
"ufw": {"/usr/sbin/ufw", "/sbin/ufw"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return linuxCommandPaths[cmd]
|
||||
}
|
||||
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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.
|
||||
|
||||
//go:build !darwin && !linux && !freebsd && !windows
|
||||
|
||||
package checks
|
||||
|
||||
func commandCandidates(_ string) []string {
|
||||
return nil
|
||||
}
|
||||
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
system32 := filepath.Join(systemRoot, "System32")
|
||||
|
||||
switch strings.ToLower(cmd) {
|
||||
case "powershell", "powershell.exe":
|
||||
return []string{
|
||||
filepath.Join(system32, "WindowsPowerShell", "v1.0", "powershell.exe"),
|
||||
}
|
||||
case "manage-bde", "manage-bde.exe":
|
||||
return []string{filepath.Join(system32, "manage-bde.exe")}
|
||||
case "netsh", "netsh.exe":
|
||||
return []string{filepath.Join(system32, "netsh.exe")}
|
||||
case "w32tm", "w32tm.exe":
|
||||
return []string{filepath.Join(system32, "w32tm.exe")}
|
||||
case "sc", "sc.exe":
|
||||
return []string{filepath.Join(system32, "sc.exe")}
|
||||
case "net", "net.exe":
|
||||
return []string{filepath.Join(system32, "net.exe")}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
84
pkg/deviceagent/checks/shared.go
Normal file
84
pkg/deviceagent/checks/shared.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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 checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check keys shared across OS implementations.
|
||||
const (
|
||||
KeyDiskEncryption = "DISK_ENCRYPTION"
|
||||
KeyScreenLock = "SCREEN_LOCK"
|
||||
KeyFirewallEnabled = "FIREWALL_ENABLED"
|
||||
KeyTimeSync = "TIME_SYNC"
|
||||
KeyOSVersion = "OS_VERSION"
|
||||
KeyAutoUpdate = "AUTO_UPDATE"
|
||||
KeyPasswordPolicy = "PASSWORD_POLICY"
|
||||
KeyRemoteLogin = "REMOTE_LOGIN"
|
||||
KeyMalwareProtection = "MALWARE_PROTECTION"
|
||||
)
|
||||
|
||||
type funcCheck struct {
|
||||
key string
|
||||
run func(ctx context.Context) Result
|
||||
}
|
||||
|
||||
func (c funcCheck) Key() string { return c.key }
|
||||
|
||||
func (c funcCheck) Run(ctx context.Context) Result {
|
||||
r := c.run(ctx)
|
||||
if r.CheckKey == "" {
|
||||
r.CheckKey = c.key
|
||||
}
|
||||
if r.ObservedAt.IsZero() {
|
||||
r.ObservedAt = time.Now().UTC()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func pass(ev map[string]any) Result {
|
||||
return Result{Status: StatusPass, Evidence: ev}
|
||||
}
|
||||
|
||||
func fail(ev map[string]any) Result {
|
||||
return Result{Status: StatusFail, Evidence: ev}
|
||||
}
|
||||
|
||||
func unknown(ev map[string]any) Result {
|
||||
return Result{Status: StatusUnknown, Evidence: ev}
|
||||
}
|
||||
|
||||
func notApplicable(ev map[string]any) Result {
|
||||
return Result{Status: StatusNotApplicable, Evidence: ev}
|
||||
}
|
||||
|
||||
// truncate limits oversized evidence values.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// errString returns "" for a nil error.
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
24
pkg/deviceagent/checks/status.go
Normal file
24
pkg/deviceagent/checks/status.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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 checks
|
||||
|
||||
// Status is the posture status sent to the agent API.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "PASS"
|
||||
StatusFail Status = "FAIL"
|
||||
StatusUnknown Status = "UNKNOWN"
|
||||
StatusNotApplicable Status = "NOT_APPLICABLE"
|
||||
)
|
||||
234
pkg/deviceagent/client.go
Normal file
234
pkg/deviceagent/client.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
)
|
||||
|
||||
type (
|
||||
// Client calls the /api/agent/v1 REST API.
|
||||
Client struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
UserAgent string
|
||||
HTTP *http.Client
|
||||
}
|
||||
)
|
||||
|
||||
// NewClient creates an API client.
|
||||
func NewClient(serverURL, apiKey, userAgent string) *Client {
|
||||
httpClient := httpclient.DefaultPooledClient()
|
||||
httpClient.Timeout = 30 * time.Second
|
||||
|
||||
return &Client{
|
||||
ServerURL: strings.TrimRight(serverURL, "/"),
|
||||
APIKey: apiKey,
|
||||
UserAgent: userAgent,
|
||||
HTTP: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
EnrollRequest struct {
|
||||
EnrollmentToken string `json:"enrollment_token"`
|
||||
HardwareUUID string `json:"hardware_uuid"`
|
||||
SerialNumber *string `json:"serial_number,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
Platform string `json:"platform"`
|
||||
OSVersion string `json:"os_version"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
}
|
||||
|
||||
EnrollResponse struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
||||
PostureSeconds int `json:"posture_interval_seconds"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
HeartbeatRequest struct {
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
UptimeSec int64 `json:"uptime_seconds,omitempty"`
|
||||
}
|
||||
|
||||
HeartbeatResponse struct {
|
||||
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
||||
PostureSeconds int `json:"posture_interval_seconds"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
PostureResultPayload struct {
|
||||
CheckKey string `json:"check_key"`
|
||||
Status string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
PosturesRequest struct {
|
||||
Results []PostureResultPayload `json:"results"`
|
||||
}
|
||||
)
|
||||
|
||||
// Enroll exchanges an enrollment token for a device key.
|
||||
func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) {
|
||||
var resp EnrollResponse
|
||||
if err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/enroll",
|
||||
false,
|
||||
req,
|
||||
&resp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Heartbeat sends a periodic device heartbeat.
|
||||
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
||||
var resp HeartbeatResponse
|
||||
if err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/heartbeat",
|
||||
true,
|
||||
req,
|
||||
&resp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PushPostures sends posture check results.
|
||||
func (c *Client) PushPostures(ctx context.Context, results []PostureResultPayload) error {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/postures",
|
||||
true,
|
||||
PosturesRequest{Results: results},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Unenroll asks the server to revoke the device.
|
||||
func (c *Client) Unenroll(ctx context.Context) error {
|
||||
return c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/unenroll",
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// HTTPError captures a non-2xx API response.
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return fmt.Sprintf("agent api: %d %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// IsUnauthorized reports whether err is an API 401.
|
||||
func IsUnauthorized(err error) bool {
|
||||
var herr *HTTPError
|
||||
if !errors.As(err, &herr) {
|
||||
return false
|
||||
}
|
||||
return herr.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) do(
|
||||
ctx context.Context,
|
||||
method, path string,
|
||||
authed bool,
|
||||
in any,
|
||||
out any,
|
||||
) error {
|
||||
url := c.ServerURL + path
|
||||
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
buf, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal request: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
|
||||
if authed {
|
||||
if c.APIKey == "" {
|
||||
return errors.New("agent client: no api key set")
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot perform request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
buf, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(buf))}
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("cannot decode response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
145
pkg/deviceagent/config.go
Normal file
145
pkg/deviceagent/config.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// 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 deviceagent implements the probo host agent.
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConfigFileName stores persisted agent config.
|
||||
ConfigFileName = "config.json"
|
||||
|
||||
// DefaultHeartbeatInterval is the default heartbeat cadence.
|
||||
DefaultHeartbeatInterval = 5 * time.Minute
|
||||
// MinHeartbeatInterval is the minimum heartbeat cadence.
|
||||
MinHeartbeatInterval = 1 * time.Minute
|
||||
|
||||
// DefaultPostureInterval is the default posture cadence.
|
||||
DefaultPostureInterval = 1 * time.Hour
|
||||
// MinPostureInterval is the minimum posture cadence.
|
||||
MinPostureInterval = 15 * time.Minute
|
||||
|
||||
// DefaultUpdateInterval is the default cadence at which the
|
||||
// agent checks for new releases.
|
||||
DefaultUpdateInterval = 4 * time.Hour
|
||||
// MinUpdateInterval is the floor used when a smaller value is
|
||||
// configured. Updates are network and disk heavy, so we cap
|
||||
// frequency to once per hour.
|
||||
MinUpdateInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
// Config is the persisted agent configuration.
|
||||
Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
HeartbeatInterval time.Duration `json:"heartbeat_interval,omitempty"`
|
||||
PostureInterval time.Duration `json:"posture_interval,omitempty"`
|
||||
UpdateInterval time.Duration `json:"update_interval,omitempty"`
|
||||
UpdatesDisabled bool `json:"updates_disabled,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// ConfigPath returns the absolute path to the agent's config file.
|
||||
func ConfigPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
return filepath.Join(dir, ConfigFileName)
|
||||
}
|
||||
|
||||
// LoadConfig reads config from disk.
|
||||
func LoadConfig(dir string) (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read config: %w", err)
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode config: %w", err)
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes config to disk with mode 0600.
|
||||
func SaveConfig(dir string, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("nil config")
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create config dir: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encode config: %w", err)
|
||||
}
|
||||
|
||||
path := ConfigPath(dir)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
c.HeartbeatInterval = normalizeHeartbeatInterval(c.HeartbeatInterval)
|
||||
c.PostureInterval = normalizePostureInterval(c.PostureInterval)
|
||||
c.UpdateInterval = normalizeUpdateInterval(c.UpdateInterval)
|
||||
}
|
||||
|
||||
func normalizeHeartbeatInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultHeartbeatInterval, MinHeartbeatInterval)
|
||||
}
|
||||
|
||||
func normalizePostureInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultPostureInterval, MinPostureInterval)
|
||||
}
|
||||
|
||||
func normalizeUpdateInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultUpdateInterval, MinUpdateInterval)
|
||||
}
|
||||
|
||||
func normalizeInterval(v, fallback, floor time.Duration) time.Duration {
|
||||
if v <= 0 {
|
||||
v = fallback
|
||||
}
|
||||
|
||||
if v < floor {
|
||||
return floor
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
23
pkg/deviceagent/config_paths_other.go
Normal file
23
pkg/deviceagent/config_paths_other.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package deviceagent
|
||||
|
||||
// DefaultConfigDir returns the directory under which the agent's config
|
||||
// and keystore live on non-Windows hosts.
|
||||
func DefaultConfigDir() string {
|
||||
return "/var/lib/probo-agent"
|
||||
}
|
||||
31
pkg/deviceagent/config_paths_windows.go
Normal file
31
pkg/deviceagent/config_paths_windows.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultConfigDir returns the directory under which the agent's config
|
||||
// and keystore live on Windows.
|
||||
func DefaultConfigDir() string {
|
||||
programData := os.Getenv("ProgramData")
|
||||
if programData == "" {
|
||||
programData = `C:\ProgramData`
|
||||
}
|
||||
|
||||
return filepath.Join(programData, "Probo", "agent")
|
||||
}
|
||||
71
pkg/deviceagent/config_test.go
Normal file
71
pkg/deviceagent/config_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfig_applyDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"uses defaults when unset",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, DefaultHeartbeatInterval, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, DefaultPostureInterval, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"clamps values below minimum floors",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
HeartbeatInterval: 10 * time.Second,
|
||||
PostureInterval: 1 * time.Minute,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, MinHeartbeatInterval, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, MinPostureInterval, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps values above floors",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
HeartbeatInterval: 3 * time.Minute,
|
||||
PostureInterval: 2 * time.Hour,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, 3*time.Minute, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, 2*time.Hour, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
}
|
||||
98
pkg/deviceagent/hostinfo.go
Normal file
98
pkg/deviceagent/hostinfo.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// HostInfo is the device identity reported by the agent.
|
||||
HostInfo struct {
|
||||
Hostname string
|
||||
Platform string
|
||||
OSVersion string
|
||||
HardwareUUID string
|
||||
SerialNumber *string
|
||||
}
|
||||
)
|
||||
|
||||
// CollectHostInfo gathers host identity using best-effort probes.
|
||||
func CollectHostInfo() HostInfo {
|
||||
info := HostInfo{
|
||||
Platform: platformString(),
|
||||
}
|
||||
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
info.Hostname = h
|
||||
}
|
||||
|
||||
if info.Hostname == "" {
|
||||
info.Hostname = "unknown-host"
|
||||
}
|
||||
|
||||
info.OSVersion = collectOSVersion()
|
||||
info.HardwareUUID = collectHardwareUUID()
|
||||
if sn := collectSerialNumber(); sn != "" {
|
||||
info.SerialNumber = &sn
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// hashFallbackUUID derives a stable fallback from hostname and MAC.
|
||||
func hashFallbackUUID() string {
|
||||
hostname, _ := os.Hostname()
|
||||
mac := firstStableMAC()
|
||||
h := sha256.New()
|
||||
h.Write([]byte(hostname))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(mac))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func firstStableMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ifc.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
return ifc.HardwareAddr.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// runQuiet runs a command and returns trimmed stdout.
|
||||
func runQuiet(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
92
pkg/deviceagent/hostinfo_darwin.go
Normal file
92
pkg/deviceagent/hostinfo_darwin.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "DARWIN"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "sw_vers", "-productVersion")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice")
|
||||
if uuid := extractValue(out, "IOPlatformUUID"); uuid != "" {
|
||||
return uuid
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice")
|
||||
return extractValue(out, "IOPlatformSerialNumber")
|
||||
}
|
||||
|
||||
// extractValue parses ioreg key/value output.
|
||||
func extractValue(s, key string) string {
|
||||
idx := strings.Index(s, "\""+key+"\"")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := s[idx:]
|
||||
eq := strings.Index(rest, "=")
|
||||
if eq < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest = strings.TrimSpace(rest[eq+1:])
|
||||
rest = strings.TrimPrefix(rest, "<")
|
||||
rest = strings.TrimPrefix(rest, ">")
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
rest = rest[1:]
|
||||
end := strings.Index(rest, "\"")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
|
||||
end := strings.IndexAny(rest, "\r\n")
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
57
pkg/deviceagent/hostinfo_freebsd.go
Normal file
57
pkg/deviceagent/hostinfo_freebsd.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "FREEBSD"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-r")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "kenv", "smbios.system.uuid")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "kenv", "smbios.system.serial")
|
||||
return out
|
||||
}
|
||||
93
pkg/deviceagent/hostinfo_linux.go
Normal file
93
pkg/deviceagent/hostinfo_linux.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "LINUX"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
if prettyName := parseOSReleasePrettyName(data); prettyName != "" {
|
||||
return prettyName
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
for _, path := range []string{
|
||||
"/sys/class/dmi/id/product_uuid",
|
||||
"/etc/machine-id",
|
||||
"/var/lib/dbus/machine-id",
|
||||
} {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if s := strings.TrimSpace(string(data)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
if data, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOSReleasePrettyName(data []byte) string {
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if k, v, ok := splitKV(line); ok {
|
||||
if k == "PRETTY_NAME" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitKV(line string) (string, string, bool) {
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
k := strings.TrimSpace(line[:eq])
|
||||
v := strings.TrimSpace(line[eq+1:])
|
||||
v = strings.Trim(v, `"`)
|
||||
|
||||
return k, v, true
|
||||
}
|
||||
44
pkg/deviceagent/hostinfo_other.go
Normal file
44
pkg/deviceagent/hostinfo_other.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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.
|
||||
|
||||
//go:build !darwin && !linux && !freebsd && !windows
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return strings.ToUpper(runtime.GOOS)
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
return ""
|
||||
}
|
||||
70
pkg/deviceagent/hostinfo_windows.go
Normal file
70
pkg/deviceagent/hostinfo_windows.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "WINDOWS"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "cmd", "/C", "ver")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// `wmic` is deprecated; `Get-CimInstance` requires PowerShell.
|
||||
out, _ := runQuiet(
|
||||
ctx,
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystemProduct).UUID",
|
||||
)
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(
|
||||
ctx,
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_BIOS).SerialNumber",
|
||||
)
|
||||
return out
|
||||
}
|
||||
84
pkg/deviceagent/keystore.go
Normal file
84
pkg/deviceagent/keystore.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyFileName stores the device API key on disk.
|
||||
const KeyFileName = "agent.key"
|
||||
|
||||
// ErrKeyNotFound is returned when no key file exists.
|
||||
var ErrKeyNotFound = errors.New("agent key not found")
|
||||
|
||||
// KeyPath returns the absolute path of the device API key file.
|
||||
func KeyPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
return filepath.Join(dir, KeyFileName)
|
||||
}
|
||||
|
||||
// SaveAPIKey writes the API key to disk with mode 0600.
|
||||
func SaveAPIKey(dir, key string) error {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create keystore dir: %w", err)
|
||||
}
|
||||
|
||||
path := KeyPath(dir)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(strings.TrimSpace(key)+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAPIKey reads the API key from disk.
|
||||
func LoadAPIKey(dir string) (string, error) {
|
||||
data, err := os.ReadFile(KeyPath(dir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cannot read agent key: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// DeleteAPIKey removes the API key file.
|
||||
func DeleteAPIKey(dir string) error {
|
||||
if err := os.Remove(KeyPath(dir)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot delete agent key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
144
pkg/deviceagent/posture_queue.go
Normal file
144
pkg/deviceagent/posture_queue.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pendingPosturesFileName = "pending-postures.json"
|
||||
maxPendingPostureBatches = 96
|
||||
)
|
||||
|
||||
type pendingPostureBatch struct {
|
||||
QueuedAt time.Time `json:"queued_at"`
|
||||
Results []PostureResultPayload `json:"results"`
|
||||
}
|
||||
|
||||
func pendingPosturesPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
return filepath.Join(dir, pendingPosturesFileName)
|
||||
}
|
||||
|
||||
func loadPendingPostureBatches(dir string) ([]pendingPostureBatch, error) {
|
||||
data, err := os.ReadFile(pendingPosturesPath(dir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot read pending postures: %w", err)
|
||||
}
|
||||
|
||||
var batches []pendingPostureBatch
|
||||
if err := json.Unmarshal(data, &batches); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pending postures: %w", err)
|
||||
}
|
||||
|
||||
filtered := make([]pendingPostureBatch, 0, len(batches))
|
||||
for _, batch := range batches {
|
||||
if len(batch.Results) == 0 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, batch)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func savePendingPostureBatches(dir string, batches []pendingPostureBatch) error {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
path := pendingPosturesPath(dir)
|
||||
if len(batches) == 0 {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot delete pending postures: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create pending posture dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(batches, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encode pending postures: %w", err)
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write pending postures: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace pending postures: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func enqueuePendingPostureBatch(
|
||||
dir string,
|
||||
results []PostureResultPayload,
|
||||
queuedAt time.Time,
|
||||
) (int, error) {
|
||||
if len(results) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
clonedResults := make([]PostureResultPayload, len(results))
|
||||
copy(clonedResults, results)
|
||||
|
||||
batches = append(
|
||||
batches,
|
||||
pendingPostureBatch{
|
||||
QueuedAt: queuedAt.UTC(),
|
||||
Results: clonedResults,
|
||||
},
|
||||
)
|
||||
|
||||
dropped := 0
|
||||
if len(batches) > maxPendingPostureBatches {
|
||||
dropped = len(batches) - maxPendingPostureBatches
|
||||
batches = batches[dropped:]
|
||||
}
|
||||
|
||||
if err := savePendingPostureBatches(dir, batches); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
func clearPendingPostureBatches(dir string) error {
|
||||
return savePendingPostureBatches(dir, nil)
|
||||
}
|
||||
237
pkg/deviceagent/posture_queue_test.go
Normal file
237
pkg/deviceagent/posture_queue_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// 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 deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPendingPostureQueue_EnqueueTrimsOldestBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
for i := range maxPendingPostureBatches + 3 {
|
||||
results := []PostureResultPayload{
|
||||
{
|
||||
CheckKey: fmt.Sprintf("check-%d", i),
|
||||
Status: "pass",
|
||||
ObservedAt: time.Unix(int64(i), 0).UTC(),
|
||||
},
|
||||
}
|
||||
dropped, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
results,
|
||||
time.Unix(int64(i), 0),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if i < maxPendingPostureBatches {
|
||||
assert.Equal(t, 0, dropped)
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, 1, dropped)
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, maxPendingPostureBatches)
|
||||
assert.Equal(t, "check-3", batches[0].Results[0].CheckKey)
|
||||
assert.Equal(t, "check-98", batches[len(batches)-1].Results[0].CheckKey)
|
||||
}
|
||||
|
||||
func TestAgent_flushQueuedPostures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"clears queue when all batches are flushed",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.flushQueuedPostures(context.Background())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, batches, 0)
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps unsent tail when a later flush request fails",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.flushQueuedPostures(context.Background())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, 1)
|
||||
assert.Equal(t, "second", batches[0].Results[0].CheckKey)
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"applies retry backoff with jitter gate after failures",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
calls.Add(1)
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Unix(10_000, 0).UTC()
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.now = func() time.Time { return now }
|
||||
a.randInt63n = func(n int64) int64 { return n / 2 }
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
assert.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff)
|
||||
firstRetryAt := a.pendingFlushRetryAt
|
||||
require.True(t, firstRetryAt.After(now))
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
|
||||
now = firstRetryAt.Add(time.Second)
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
assert.Equal(t, pendingFlushBackoffMin*2, a.pendingFlushBackoff)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"resets retry backoff after successful flush",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
call := calls.Add(1)
|
||||
if call == 1 {
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Unix(20_000, 0).UTC()
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.now = func() time.Time { return now }
|
||||
a.randInt63n = func(n int64) int64 { return n / 2 }
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
require.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff)
|
||||
retryAt := a.pendingFlushRetryAt
|
||||
require.True(t, retryAt.After(now))
|
||||
|
||||
now = retryAt.Add(time.Second)
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
assert.Zero(t, a.pendingFlushBackoff)
|
||||
assert.True(t, a.pendingFlushRetryAt.IsZero())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, batches, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
33
pkg/deviceagent/service/service.go
Normal file
33
pkg/deviceagent/service/service.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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 service installs and uninstalls OS service units for probo-agent.
|
||||
package service
|
||||
|
||||
// Config carries installation parameters shared across platforms.
|
||||
type Config struct {
|
||||
// ExePath is the agent binary path.
|
||||
ExePath string
|
||||
// Dir is the agent state directory.
|
||||
Dir string
|
||||
// Label is the service identifier.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Default service identifiers by platform.
|
||||
const (
|
||||
DefaultLabel = "com.getprobo.agent"
|
||||
DefaultUnixName = "probo-agent"
|
||||
DefaultWindowsName = "ProboAgent"
|
||||
)
|
||||
119
pkg/deviceagent/service/service_darwin.go
Normal file
119
pkg/deviceagent/service/service_darwin.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const plistPath = "/Library/LaunchDaemons/com.getprobo.agent.plist"
|
||||
|
||||
const launchdPlistTmpl = `<?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>{{xml .Label}}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{xml .ExePath}}</string>
|
||||
<string>run</string>
|
||||
<string>--dir</string>
|
||||
<string>{{xml .Dir}}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/probo-agent.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/probo-agent.log</string>
|
||||
<key>UserName</key>
|
||||
<string>root</string>
|
||||
<key>GroupName</key>
|
||||
<string>wheel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
func xmlEscape(v string) (string, error) {
|
||||
var sb strings.Builder
|
||||
if err := xml.EscapeText(&sb, []byte(v)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Install writes and boots the launchd plist.
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
if cfg.Label == "" {
|
||||
cfg.Label = DefaultLabel
|
||||
}
|
||||
|
||||
tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse plist template: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure launch daemons directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(plistPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write plist (need root?): %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := tmpl.Execute(f, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render plist: %w", err)
|
||||
}
|
||||
|
||||
// `bootout` first keeps install idempotent.
|
||||
_ = exec.Command("launchctl", "bootout", "system", plistPath).Run()
|
||||
if out, err := exec.Command("launchctl", "bootstrap", "system", plistPath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run launchctl bootstrap: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall bootouts and removes the launchd plist.
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("launchctl", "bootout", "system", plistPath).Run()
|
||||
if err := os.Remove(plistPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove plist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
97
pkg/deviceagent/service/service_freebsd.go
Normal file
97
pkg/deviceagent/service/service_freebsd.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
rcScriptPath = "/usr/local/etc/rc.d/probo_agent"
|
||||
)
|
||||
|
||||
// FreeBSD rc.d script template.
|
||||
const rcScriptTmpl = `#!/bin/sh
|
||||
#
|
||||
# PROVIDE: probo_agent
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name=probo_agent
|
||||
rcvar=probo_agent_enable
|
||||
desc="Probo device posture agent"
|
||||
pidfile="/var/run/${name}.pid"
|
||||
procname="{{.ExePath}}"
|
||||
command=/usr/sbin/daemon
|
||||
command_args="-r -P ${pidfile} -- \"{{.ExePath}}\" run --dir \"{{.Dir}}\""
|
||||
|
||||
load_rc_config $name
|
||||
: ${probo_agent_enable:=YES}
|
||||
|
||||
run_rc_command "$1"
|
||||
`
|
||||
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
rcTmpl, err := template.New("rc").Parse(rcScriptTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse rc.d template: %w", err)
|
||||
}
|
||||
|
||||
sf, err := os.OpenFile(rcScriptPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write rc.d script (need root?): %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = sf.Close() }()
|
||||
if err := rcTmpl.Execute(sf, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render rc.d script: %w", err)
|
||||
}
|
||||
|
||||
if out, err := exec.Command("service", "probo_agent", "enable").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run service probo_agent enable: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("service", "probo_agent", "start").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run service probo_agent start: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("service", "probo_agent", "stop").Run()
|
||||
_ = exec.Command("service", "probo_agent", "disable").Run()
|
||||
|
||||
if err := os.Remove(rcScriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove rc.d script: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
97
pkg/deviceagent/service/service_linux.go
Normal file
97
pkg/deviceagent/service/service_linux.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
systemdUnitPath = "/etc/systemd/system/probo-agent.service"
|
||||
)
|
||||
|
||||
const systemdUnitTmpl = `[Unit]
|
||||
Description=Probo device posture agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={{.ExePath}} run --dir {{.Dir}}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# 75 is the exit code emitted after a successful self-update.
|
||||
# Treat it as a normal exit so the unit restarts without entering
|
||||
# the "failed" state.
|
||||
SuccessExitStatus=75
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
tmpl, err := template.New("unit").Parse(systemdUnitTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse unit template: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(systemdUnitPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write systemd unit (need root?): %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := tmpl.Execute(f, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render systemd unit: %w", err)
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run systemctl daemon-reload: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "enable", "--now", "probo-agent.service").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run systemctl enable --now: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("systemctl", "disable", "--now", "probo-agent.service").Run()
|
||||
if err := os.Remove(systemdUnitPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove systemd unit: %w", err)
|
||||
}
|
||||
|
||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
73
pkg/deviceagent/service/service_windows.go
Normal file
73
pkg/deviceagent/service/service_windows.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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 service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Install registers and starts the Windows service via sc.exe.
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
name := DefaultWindowsName
|
||||
|
||||
bin := fmt.Sprintf(`"%s" run --dir "%s"`, cfg.ExePath, cfg.Dir)
|
||||
if out, err := exec.Command(
|
||||
"sc.exe",
|
||||
"create",
|
||||
name,
|
||||
"binPath=",
|
||||
bin,
|
||||
"start=",
|
||||
"auto",
|
||||
"DisplayName=",
|
||||
"Probo Device Posture Agent",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
// Restart on failure.
|
||||
if out, err := exec.Command(
|
||||
"sc.exe",
|
||||
"failure",
|
||||
name,
|
||||
"reset=",
|
||||
"86400",
|
||||
"actions=",
|
||||
"restart/1000/restart/1000/restart/1000",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe failure: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if out, err := exec.Command("sc.exe", "start", name).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe start: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
name := DefaultWindowsName
|
||||
_ = exec.Command("sc.exe", "stop", name).Run()
|
||||
if out, err := exec.Command("sc.exe", "delete", name).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe delete: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
151
pkg/deviceagent/update/archive.go
Normal file
151
pkg/deviceagent/update/archive.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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 update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
maxExtractedFileSize = 200 * 1024 * 1024 // 200 MiB hard cap per file
|
||||
)
|
||||
|
||||
// extractBinary extracts the agent binary at
|
||||
// `<ArchiveDir>/<BinaryName>` from archivePath into workDir and
|
||||
// returns the absolute path of the written binary.
|
||||
func extractBinary(archivePath string, layout AssetLayout, workDir string) (string, error) {
|
||||
wantPath := path.Join(layout.ArchiveDir, layout.BinaryName)
|
||||
dest := filepath.Join(workDir, "probo-agent.new")
|
||||
|
||||
if layout.IsZip {
|
||||
if err := extractZipFile(archivePath, wantPath, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := extractTarGzFile(archivePath, wantPath, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
return "", fmt.Errorf("update: extracted binary missing: %w", err)
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func extractTarGzFile(archivePath, wantPath, dest string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open archive: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read tar entry: %w", err)
|
||||
}
|
||||
|
||||
if path.Clean(hdr.Name) != wantPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
return fmt.Errorf("update: %s is not a regular file", wantPath)
|
||||
}
|
||||
|
||||
return writeStream(dest, tr, 0o755)
|
||||
}
|
||||
|
||||
return fmt.Errorf("update: %s missing from archive", wantPath)
|
||||
}
|
||||
|
||||
func extractZipFile(archivePath, wantPath, dest string) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open zip: %w", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
for _, f := range r.File {
|
||||
if path.Clean(f.Name) != wantPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
return fmt.Errorf("update: %s is a directory", wantPath)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s in zip: %w", wantPath, err)
|
||||
}
|
||||
|
||||
err = writeStream(dest, rc, 0o755)
|
||||
_ = rc.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("update: %s missing from archive", wantPath)
|
||||
}
|
||||
|
||||
func writeStream(dest string, src io.Reader, mode os.FileMode) error {
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, io.LimitReader(src, maxExtractedFileSize+1)); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("cannot write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("cannot close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if stat.Size() > maxExtractedFileSize {
|
||||
_ = os.Remove(dest)
|
||||
return fmt.Errorf("update: extracted file exceeds %d bytes", maxExtractedFileSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
92
pkg/deviceagent/update/asset.go
Normal file
92
pkg/deviceagent/update/asset.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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 update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AssetLayout describes the names used by the release pipeline for a
|
||||
// (goos, goarch) combination. The fields mirror what the
|
||||
// release-probo-agent.yaml workflow produces.
|
||||
type AssetLayout struct {
|
||||
// ArchiveName is the file name of the published archive
|
||||
// (e.g. probo-agent_Linux_x86_64.tar.gz).
|
||||
ArchiveName string
|
||||
// ArchiveDir is the top-level directory inside the archive
|
||||
// (e.g. probo-agent_Linux_x86_64).
|
||||
ArchiveDir string
|
||||
// BinaryName is the agent binary file name inside the archive
|
||||
// (e.g. probo-agent or probo-agent.exe).
|
||||
BinaryName string
|
||||
// IsZip is true for Windows builds, which ship as zip archives.
|
||||
// Other platforms ship as gzipped tar.
|
||||
IsZip bool
|
||||
}
|
||||
|
||||
// LayoutFor returns the asset layout for a given (goos, goarch).
|
||||
//
|
||||
// The mapping is the inverse of the case statements in the release
|
||||
// workflow: linux/Linux, darwin/Darwin, windows/Windows, freebsd/Freebsd
|
||||
// and amd64 -> x86_64 (others kept as-is).
|
||||
func LayoutFor(goos, goarch string) (AssetLayout, error) {
|
||||
osLabel, err := osLabel(goos)
|
||||
if err != nil {
|
||||
return AssetLayout{}, err
|
||||
}
|
||||
|
||||
archLabel := archLabel(goarch)
|
||||
dir := fmt.Sprintf("probo-agent_%s_%s", osLabel, archLabel)
|
||||
|
||||
binary := "probo-agent"
|
||||
isZip := false
|
||||
ext := "tar.gz"
|
||||
if goos == "windows" {
|
||||
binary += ".exe"
|
||||
isZip = true
|
||||
ext = "zip"
|
||||
}
|
||||
|
||||
return AssetLayout{
|
||||
ArchiveName: fmt.Sprintf("%s.%s", dir, ext),
|
||||
ArchiveDir: dir,
|
||||
BinaryName: binary,
|
||||
IsZip: isZip,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func osLabel(goos string) (string, error) {
|
||||
switch strings.ToLower(goos) {
|
||||
case "linux":
|
||||
return "Linux", nil
|
||||
case "darwin":
|
||||
return "Darwin", nil
|
||||
case "windows":
|
||||
return "Windows", nil
|
||||
case "freebsd":
|
||||
return "Freebsd", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported GOOS %q for auto-update", goos)
|
||||
}
|
||||
|
||||
func archLabel(goarch string) string {
|
||||
if goarch == "amd64" {
|
||||
return "x86_64"
|
||||
}
|
||||
|
||||
return goarch
|
||||
}
|
||||
69
pkg/deviceagent/update/asset_test.go
Normal file
69
pkg/deviceagent/update/asset_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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 update
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLayoutFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
goos, goarch string
|
||||
archive string
|
||||
dir string
|
||||
binary string
|
||||
isZip bool
|
||||
}{
|
||||
{"linux", "amd64", "probo-agent_Linux_x86_64.tar.gz", "probo-agent_Linux_x86_64", "probo-agent", false},
|
||||
{"linux", "arm64", "probo-agent_Linux_arm64.tar.gz", "probo-agent_Linux_arm64", "probo-agent", false},
|
||||
{"darwin", "amd64", "probo-agent_Darwin_x86_64.tar.gz", "probo-agent_Darwin_x86_64", "probo-agent", false},
|
||||
{"darwin", "arm64", "probo-agent_Darwin_arm64.tar.gz", "probo-agent_Darwin_arm64", "probo-agent", false},
|
||||
{"windows", "amd64", "probo-agent_Windows_x86_64.zip", "probo-agent_Windows_x86_64", "probo-agent.exe", true},
|
||||
{"windows", "arm64", "probo-agent_Windows_arm64.zip", "probo-agent_Windows_arm64", "probo-agent.exe", true},
|
||||
{"freebsd", "amd64", "probo-agent_Freebsd_x86_64.tar.gz", "probo-agent_Freebsd_x86_64", "probo-agent", false},
|
||||
{"freebsd", "arm64", "probo-agent_Freebsd_arm64.tar.gz", "probo-agent_Freebsd_arm64", "probo-agent", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(
|
||||
tc.goos+"/"+tc.goarch,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor(tc.goos, tc.goarch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.archive, layout.ArchiveName)
|
||||
assert.Equal(t, tc.dir, layout.ArchiveDir)
|
||||
assert.Equal(t, tc.binary, layout.BinaryName)
|
||||
assert.Equal(t, tc.isZip, layout.IsZip)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"unsupported GOOS",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := LayoutFor("plan9", "amd64")
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
101
pkg/deviceagent/update/install_unix.go
Normal file
101
pkg/deviceagent/update/install_unix.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// replaceBinary replaces the file at dst with src.
|
||||
//
|
||||
// On Unix the rename is atomic: the kernel keeps the running
|
||||
// executable mapped via its inode, while the destination path now
|
||||
// points at the new binary on disk. The next exec (after the
|
||||
// supervisor restarts the process) loads the new code.
|
||||
//
|
||||
// We try a same-directory rename first, then fall back to a
|
||||
// copy + atomic rename when src and dst live on different
|
||||
// filesystems (e.g. when /tmp is a tmpfs separate from /usr/local/bin).
|
||||
func replaceBinary(dst, src string) error {
|
||||
if err := os.Chmod(src, 0o755); err != nil {
|
||||
return fmt.Errorf("cannot chmod new binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cross-filesystem fallback: copy into <dst>.new, fsync,
|
||||
// then rename within the destination directory.
|
||||
staging := dst + ".new"
|
||||
if err := copyFile(src, staging); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(staging, 0o755); err != nil {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot chmod staged binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(staging, dst); err != nil {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot atomically replace %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot copy to %s: %w", dst, err)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot fsync %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot close %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupAfterRestart removes any leftover .old binary from a
|
||||
// previous Windows-style swap. On Unix this is a no-op.
|
||||
func CleanupAfterRestart(_ string) {}
|
||||
110
pkg/deviceagent/update/install_windows.go
Normal file
110
pkg/deviceagent/update/install_windows.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const oldSuffix = ".old"
|
||||
|
||||
// replaceBinary swaps dst with src on Windows.
|
||||
//
|
||||
// Windows blocks deletion / replacement of the running .exe but does
|
||||
// allow renaming a locked .exe out of the way. We:
|
||||
//
|
||||
// 1. Stage src as `<dst>.new` (same directory, so the final rename is
|
||||
// just a metadata update and won't cross volumes).
|
||||
// 2. Move the running binary to `<dst>.old` (NTFS lets us rename a
|
||||
// locked exe).
|
||||
// 3. Move `<dst>.new` into place at `<dst>`.
|
||||
//
|
||||
// On the next start the agent's main() calls CleanupAfterRestart to
|
||||
// best-effort delete `<dst>.old`.
|
||||
func replaceBinary(dst, src string) error {
|
||||
staging := dst + ".new"
|
||||
if err := copyFile(src, staging); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := dst + oldSuffix
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
if err := os.Rename(dst, oldPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot move running binary aside: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(staging, dst); err != nil {
|
||||
// Try to roll back the running binary swap.
|
||||
_ = os.Rename(oldPath, dst)
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot install new binary at %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot copy to %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot fsync %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot close %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupAfterRestart removes the previous-version binary left behind
|
||||
// by replaceBinary. Best-effort: callers ignore errors, so a still-locked
|
||||
// `<exePath>.old` is fine and will be retried on the next boot.
|
||||
func CleanupAfterRestart(exePath string) {
|
||||
if exePath == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(exePath + oldSuffix)
|
||||
}
|
||||
588
pkg/deviceagent/update/update.go
Normal file
588
pkg/deviceagent/update/update.go
Normal file
@@ -0,0 +1,588 @@
|
||||
// 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 update self-updates the probo-agent binary from GitHub
|
||||
// Releases. The update flow is:
|
||||
//
|
||||
// 1. List the latest releases for the configured repo, filtering on a
|
||||
// tag prefix (`probo-agent/v` by default).
|
||||
// 2. Pick the highest semver newer than the agent's current version.
|
||||
// 3. Download the matching archive plus checksums.txt, verify SHA-256.
|
||||
// 4. Extract the archive and atomically replace the running binary.
|
||||
//
|
||||
// The caller is responsible for restarting the process so the OS
|
||||
// service supervisor re-execs the new binary.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRepo is the GitHub repository hosting probo-agent
|
||||
// releases.
|
||||
DefaultRepo = "getprobo/probo"
|
||||
// DefaultTagPrefix is the tag prefix used by the agent's release
|
||||
// pipeline. Releases look like `probo-agent/v0.1.0`.
|
||||
DefaultTagPrefix = "probo-agent/v"
|
||||
|
||||
defaultAPIBaseURL = "https://api.github.com"
|
||||
defaultAssetBaseURL = "https://github.com"
|
||||
defaultPageSize = 30
|
||||
defaultDownloadLimit = 200 * 1024 * 1024 // 200 MiB cap on archive size
|
||||
checksumFileName = "checksums.txt"
|
||||
checksumBundleFileName = "checksums.txt.bundle"
|
||||
)
|
||||
|
||||
// ErrNoUpdateAvailable is returned by CheckLatest when no release
|
||||
// newer than the current version exists.
|
||||
var ErrNoUpdateAvailable = errors.New("no update available")
|
||||
|
||||
type (
|
||||
// Updater self-updates the agent binary on the local host.
|
||||
Updater struct {
|
||||
Repo string
|
||||
TagPrefix string
|
||||
APIBaseURL string
|
||||
AssetBaseURL string
|
||||
CurrentVersion string
|
||||
ExePath string
|
||||
UserAgent string
|
||||
HTTP *http.Client
|
||||
Logger *log.Logger
|
||||
|
||||
// SigstoreCacheDir is the on-disk directory used by the
|
||||
// default cosign Verifier to cache Sigstore TUF metadata.
|
||||
// Required when Verifier is nil.
|
||||
SigstoreCacheDir string
|
||||
|
||||
// Verifier validates the Sigstore bundle that accompanies
|
||||
// every release. When nil, the default cosign Verifier is
|
||||
// constructed lazily on first Apply, pinned to the
|
||||
// probo-agent release workflow.
|
||||
Verifier Verifier
|
||||
|
||||
// GOOS/GOARCH override the values used to compute the
|
||||
// archive name. They default to runtime.GOOS/GOARCH and
|
||||
// exist for tests.
|
||||
GOOS string
|
||||
GOARCH string
|
||||
}
|
||||
|
||||
// Release describes a candidate update.
|
||||
Release struct {
|
||||
Version string
|
||||
Tag string
|
||||
AssetName string
|
||||
AssetURL string
|
||||
ChecksumURL string
|
||||
ChecksumBundleURL string
|
||||
}
|
||||
|
||||
githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
PublishedAt jsonTimestamp `json:"published_at"`
|
||||
}
|
||||
|
||||
githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
jsonTimestamp time.Time
|
||||
)
|
||||
|
||||
func (j *jsonTimestamp) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*j = jsonTimestamp(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New returns an Updater with sane defaults for production use.
|
||||
//
|
||||
// sigstoreCacheDir is the on-disk directory used to cache Sigstore
|
||||
// TUF metadata for cosign bundle verification. It MUST be writable by
|
||||
// the agent. A typical value is `<agent state dir>/sigstore-cache`.
|
||||
func New(currentVersion, exePath, userAgent, sigstoreCacheDir string, logger *log.Logger) *Updater {
|
||||
if logger == nil {
|
||||
logger = log.NewLogger(log.WithName("agent-update"))
|
||||
}
|
||||
|
||||
return &Updater{
|
||||
Repo: DefaultRepo,
|
||||
TagPrefix: DefaultTagPrefix,
|
||||
APIBaseURL: defaultAPIBaseURL,
|
||||
AssetBaseURL: defaultAssetBaseURL,
|
||||
CurrentVersion: currentVersion,
|
||||
ExePath: exePath,
|
||||
UserAgent: userAgent,
|
||||
Logger: logger,
|
||||
HTTP: defaultHTTPClient(logger),
|
||||
SigstoreCacheDir: sigstoreCacheDir,
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHTTPClient(logger *log.Logger) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: httpclient.DefaultPooledTransport(
|
||||
httpclient.WithLogger(logger),
|
||||
httpclient.WithSSRFProtection(),
|
||||
),
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckLatest queries GitHub for the highest semver release whose tag
|
||||
// matches u.TagPrefix and is newer than u.CurrentVersion. It returns
|
||||
// ErrNoUpdateAvailable when nothing newer exists.
|
||||
func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) {
|
||||
layout, err := LayoutFor(u.goos(), u.goarch())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releases, err := u.listReleases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := normalizeSemver(u.CurrentVersion)
|
||||
|
||||
var best *Release
|
||||
for i := range releases {
|
||||
rel := &releases[i]
|
||||
if rel.Draft || rel.Prerelease {
|
||||
continue
|
||||
}
|
||||
|
||||
ver, ok := parseTag(rel.TagName, u.TagPrefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip anything that is not strictly newer than the running
|
||||
// version. When running a dev build (`current` is empty)
|
||||
// every published release is considered newer.
|
||||
if current != "" && semver.Compare(normalizeSemver(ver), current) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if best != nil && semver.Compare(normalizeSemver(ver), normalizeSemver(best.Version)) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
assetURL, ok := findAssetURL(rel.Assets, layout.ArchiveName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checksumURL, ok := findAssetURL(rel.Assets, checksumFileName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bundleURL, ok := findAssetURL(rel.Assets, checksumBundleFileName)
|
||||
if !ok {
|
||||
// Releases without a Sigstore bundle predate the
|
||||
// signed-release pipeline and cannot be verified.
|
||||
// Skip them so the agent never auto-installs an
|
||||
// unsigned artifact.
|
||||
continue
|
||||
}
|
||||
|
||||
best = &Release{
|
||||
Version: ver,
|
||||
Tag: rel.TagName,
|
||||
AssetName: layout.ArchiveName,
|
||||
AssetURL: assetURL,
|
||||
ChecksumURL: checksumURL,
|
||||
ChecksumBundleURL: bundleURL,
|
||||
}
|
||||
}
|
||||
|
||||
if best == nil {
|
||||
return nil, ErrNoUpdateAvailable
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// Apply downloads the release archive, verifies the Sigstore bundle
|
||||
// covering checksums.txt, verifies the SHA-256 of the archive against
|
||||
// the now-trusted checksums.txt, extracts the binary to a temp
|
||||
// directory, and atomically replaces u.ExePath with the new binary.
|
||||
//
|
||||
// Failure at *any* verification step aborts the update without
|
||||
// touching the running binary.
|
||||
func (u *Updater) Apply(ctx context.Context, rel *Release) error {
|
||||
if rel == nil {
|
||||
return errors.New("nil release")
|
||||
}
|
||||
|
||||
if u.ExePath == "" {
|
||||
return errors.New("agent executable path is empty")
|
||||
}
|
||||
|
||||
if rel.ChecksumBundleURL == "" {
|
||||
return errors.New("release is not signed (no checksums.txt.bundle)")
|
||||
}
|
||||
|
||||
layout, err := LayoutFor(u.goos(), u.goarch())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verifier, err := u.resolveVerifier()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "probo-agent-update-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create update workdir: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(workDir) }()
|
||||
|
||||
archivePath := filepath.Join(workDir, layout.ArchiveName)
|
||||
if err := u.downloadFile(ctx, rel.AssetURL, archivePath); err != nil {
|
||||
return fmt.Errorf("cannot download archive: %w", err)
|
||||
}
|
||||
|
||||
checksumPath := filepath.Join(workDir, checksumFileName)
|
||||
if err := u.downloadFile(ctx, rel.ChecksumURL, checksumPath); err != nil {
|
||||
return fmt.Errorf("cannot download checksums: %w", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(workDir, checksumBundleFileName)
|
||||
if err := u.downloadFile(ctx, rel.ChecksumBundleURL, bundlePath); err != nil {
|
||||
return fmt.Errorf("cannot download sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
// Anchor the trust chain: verify that the bundle attests
|
||||
// checksums.txt was signed by the pinned release workflow,
|
||||
// before reading anything from checksums.txt.
|
||||
if err := verifier.Verify(ctx, checksumPath, bundlePath); err != nil {
|
||||
return fmt.Errorf("cannot verify sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
if err := verifyChecksum(archivePath, checksumPath, layout.ArchiveName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedBinary, err := extractBinary(archivePath, layout, workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot extract archive: %w", err)
|
||||
}
|
||||
|
||||
if err := replaceBinary(u.ExePath, extractedBinary); err != nil {
|
||||
return fmt.Errorf("cannot replace agent binary: %w", err)
|
||||
}
|
||||
|
||||
u.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent binary updated",
|
||||
log.String("version", rel.Version),
|
||||
log.String("tag", rel.Tag),
|
||||
log.String("asset", rel.AssetName),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveVerifier returns a non-nil Verifier, lazily building the
|
||||
// default cosign-backed verifier when none was injected.
|
||||
func (u *Updater) resolveVerifier() (Verifier, error) {
|
||||
if u.Verifier != nil {
|
||||
return u.Verifier, nil
|
||||
}
|
||||
|
||||
if u.SigstoreCacheDir == "" {
|
||||
return nil, errors.New("SigstoreCacheDir must be set for default cosign verifier")
|
||||
}
|
||||
|
||||
v, err := NewCosignVerifier(
|
||||
CosignVerifierConfig{
|
||||
Repo: u.Repo,
|
||||
WorkflowPath: expectedWorkflowPath,
|
||||
TagPrefix: u.TagPrefix,
|
||||
CacheDir: u.SigstoreCacheDir,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Verifier = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// listReleases returns the most recent page of releases from GitHub.
|
||||
func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) {
|
||||
apiBase := u.APIBaseURL
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBaseURL
|
||||
}
|
||||
|
||||
endpoint, err := url.JoinPath(apiBase, "repos", u.Repo, "releases")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build releases URL: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse releases URL: %w", err)
|
||||
}
|
||||
|
||||
q := parsed.Query()
|
||||
q.Set("per_page", fmt.Sprintf("%d", defaultPageSize))
|
||||
parsed.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build releases request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", u.userAgent())
|
||||
|
||||
resp, err := u.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch releases: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("cannot fetch releases: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var out []githubRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4*1024*1024)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode releases: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/octet-stream")
|
||||
req.Header.Set("User-Agent", u.userAgent())
|
||||
|
||||
resp, err := u.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("cannot fetch %s: %d %s", src, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
tmp := dst + ".part"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", tmp, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, defaultDownloadLimit+1)); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("cannot stream %s: %w", src, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("cannot close %s: %w", tmp, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat %s: %w", tmp, err)
|
||||
}
|
||||
if stat.Size() > defaultDownloadLimit {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download %s exceeds %d bytes", src, defaultDownloadLimit)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return fmt.Errorf("cannot move %s into place: %w", tmp, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) userAgent() string {
|
||||
if u.UserAgent != "" {
|
||||
return u.UserAgent
|
||||
}
|
||||
|
||||
return "probo-agent-updater"
|
||||
}
|
||||
|
||||
func (u *Updater) goos() string {
|
||||
if u.GOOS != "" {
|
||||
return u.GOOS
|
||||
}
|
||||
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (u *Updater) goarch() string {
|
||||
if u.GOARCH != "" {
|
||||
return u.GOARCH
|
||||
}
|
||||
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
// parseTag returns the version (e.g. "0.2.0") for a tag whose value
|
||||
// starts with prefix (e.g. "probo-agent/v").
|
||||
func parseTag(tag, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(tag, prefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
v := strings.TrimPrefix(tag, prefix)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !semver.IsValid("v" + v) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
|
||||
// normalizeSemver returns the canonical form expected by golang.org/x/mod/semver
|
||||
// (a "v" prefix), or "" when the input is empty / invalid.
|
||||
func normalizeSemver(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
|
||||
if !semver.IsValid(v) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func findAssetURL(assets []githubAsset, name string) (string, bool) {
|
||||
for _, a := range assets {
|
||||
if a.Name == name {
|
||||
return a.BrowserDownloadURL, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// verifyChecksum checks that the SHA-256 digest of archivePath
|
||||
// matches the entry for archiveName in the checksums.txt file
|
||||
// produced by `sha256sum *.tar.gz *.zip`.
|
||||
func verifyChecksum(archivePath, checksumPath, archiveName string) error {
|
||||
expected, err := readChecksum(checksumPath, archiveName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open archive: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("cannot hash archive: %w", err)
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
if !strings.EqualFold(actual, expected) {
|
||||
return fmt.Errorf(
|
||||
"update: checksum mismatch for %s (expected %s, got %s)",
|
||||
archiveName,
|
||||
expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChecksum(path, archiveName string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read checksums: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// `sha256sum` output is `<hex> <name>`; the GNU tool also
|
||||
// supports a single-space separator and a leading `*` flag
|
||||
// for binary mode. Handle both.
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(fields[1], "*")
|
||||
if name != archiveName {
|
||||
continue
|
||||
}
|
||||
|
||||
return strings.ToLower(fields[0]), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("update: %s missing from checksums file", archiveName)
|
||||
}
|
||||
460
pkg/deviceagent/update/update_test.go
Normal file
460
pkg/deviceagent/update/update_test.go
Normal file
@@ -0,0 +1,460 @@
|
||||
// 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 update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func TestParseTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
tag string
|
||||
prefix string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"probo-agent/v0.1.0", "probo-agent/v", "0.1.0", true},
|
||||
{"probo-agent/v1.2.3", "probo-agent/v", "1.2.3", true},
|
||||
{"v1.2.3", "probo-agent/v", "", false},
|
||||
{"probo-agent/vlatest", "probo-agent/v", "", false},
|
||||
{"probo-agent/v", "probo-agent/v", "", false},
|
||||
{"unrelated/v0.1.0", "probo-agent/v", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, ok := parseTag(tc.tag, tc.prefix)
|
||||
assert.Equal(t, tc.ok, ok, tc.tag)
|
||||
assert.Equal(t, tc.want, got, tc.tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "v0.1.0", normalizeSemver("0.1.0"))
|
||||
assert.Equal(t, "v1.2.3", normalizeSemver("v1.2.3"))
|
||||
assert.Equal(t, "v1.2.3-alpha.1", normalizeSemver("1.2.3-alpha.1"))
|
||||
assert.Equal(t, "", normalizeSemver(""))
|
||||
assert.Equal(t, "", normalizeSemver("not-a-version"))
|
||||
}
|
||||
|
||||
func TestReadChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"plain sha256sum output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
content := "" +
|
||||
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef probo-agent_Linux_x86_64.tar.gz\n" +
|
||||
"abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd probo-agent_Darwin_arm64.tar.gz\n"
|
||||
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
|
||||
|
||||
got, err := readChecksum(file, "probo-agent_Darwin_arm64.tar.gz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"binary-mode flag is stripped",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
content := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef *probo-agent_Linux_x86_64.tar.gz\n"
|
||||
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
|
||||
|
||||
got, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"missing entry returns error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
require.NoError(t, os.WriteFile(file, []byte("deadbeef other.tar.gz\n"), 0o600))
|
||||
|
||||
_, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// fakeReleaseServer simulates the GitHub releases API and the
|
||||
// browser_download_url asset endpoints.
|
||||
type fakeReleaseServer struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
|
||||
// release plumbing
|
||||
tag string
|
||||
prerelease bool
|
||||
draft bool
|
||||
|
||||
// archive plumbing
|
||||
binaryContent []byte
|
||||
archiveBytes []byte
|
||||
checksumLine string
|
||||
bundleBytes []byte
|
||||
|
||||
// when true, the release does not advertise a checksums.txt.bundle asset
|
||||
omitBundle bool
|
||||
}
|
||||
|
||||
func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, binary []byte) *fakeReleaseServer {
|
||||
t.Helper()
|
||||
|
||||
archive := buildArchive(t, layout, binary)
|
||||
sum := sha256.Sum256(archive)
|
||||
checksum := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), layout.ArchiveName)
|
||||
|
||||
frs := &fakeReleaseServer{
|
||||
t: t,
|
||||
tag: tag,
|
||||
binaryContent: binary,
|
||||
archiveBytes: archive,
|
||||
checksumLine: checksum,
|
||||
bundleBytes: []byte("dummy-sigstore-bundle"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host
|
||||
assets := []map[string]any{
|
||||
{
|
||||
"name": layout.ArchiveName,
|
||||
"browser_download_url": base + "/download/" + layout.ArchiveName,
|
||||
},
|
||||
{
|
||||
"name": checksumFileName,
|
||||
"browser_download_url": base + "/download/" + checksumFileName,
|
||||
},
|
||||
}
|
||||
if !frs.omitBundle {
|
||||
assets = append(assets, map[string]any{
|
||||
"name": checksumBundleFileName,
|
||||
"browser_download_url": base + "/download/" + checksumBundleFileName,
|
||||
})
|
||||
}
|
||||
body := []map[string]any{
|
||||
{
|
||||
"tag_name": frs.tag,
|
||||
"draft": frs.draft,
|
||||
"prerelease": frs.prerelease,
|
||||
"assets": assets,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
_ = version
|
||||
})
|
||||
mux.HandleFunc("/download/"+layout.ArchiveName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(frs.archiveBytes)
|
||||
})
|
||||
mux.HandleFunc("/download/"+checksumFileName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(frs.checksumLine))
|
||||
})
|
||||
mux.HandleFunc("/download/"+checksumBundleFileName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(frs.bundleBytes)
|
||||
})
|
||||
|
||||
frs.server = httptest.NewServer(mux)
|
||||
t.Cleanup(frs.server.Close)
|
||||
return frs
|
||||
}
|
||||
|
||||
func (f *fakeReleaseServer) URL() string { return f.server.URL }
|
||||
|
||||
func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
if layout.IsZip {
|
||||
return buildZip(t, layout, binary)
|
||||
}
|
||||
return buildTarGz(t, layout, binary)
|
||||
}
|
||||
|
||||
func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, layout.ArchiveName)
|
||||
|
||||
f, err := os.Create(out)
|
||||
require.NoError(t, err)
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||
Name: path.Join(layout.ArchiveDir, layout.BinaryName),
|
||||
Mode: 0o755,
|
||||
Size: int64(len(binary)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}))
|
||||
_, err = tw.Write(binary)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gz.Close())
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
data, err := os.ReadFile(out)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, layout.ArchiveName)
|
||||
|
||||
f, err := os.Create(out)
|
||||
require.NoError(t, err)
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create(path.Join(layout.ArchiveDir, layout.BinaryName))
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write(binary)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
data, err := os.ReadFile(out)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func newTestUpdater(server *fakeReleaseServer, currentVersion, exePath, goos, goarch string) *Updater {
|
||||
return &Updater{
|
||||
Repo: "getprobo/probo",
|
||||
TagPrefix: DefaultTagPrefix,
|
||||
APIBaseURL: server.URL(),
|
||||
AssetBaseURL: server.URL(),
|
||||
CurrentVersion: currentVersion,
|
||||
ExePath: exePath,
|
||||
UserAgent: "probo-agent-test/0.0.0",
|
||||
Logger: log.NewLogger(log.WithName("update-test")),
|
||||
HTTP: &http.Client{
|
||||
Transport: httpclient.DefaultPooledTransport(
|
||||
httpclient.WithSSRFProtection(),
|
||||
httpclient.WithSSRFAllowLoopback(),
|
||||
),
|
||||
},
|
||||
// Tests bypass the cosign verifier; production code wires
|
||||
// CosignVerifier in via Updater.SigstoreCacheDir.
|
||||
Verifier: AllowAllVerifier{},
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdater_CheckLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns release when newer version is available",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.2.0", rel.Version)
|
||||
assert.Equal(t, layout.ArchiveName, rel.AssetName)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns ErrNoUpdateAvailable when running latest",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("darwin", "arm64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("same"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "darwin", "arm64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"skips draft and prerelease tags",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0-rc.1", "0.2.0-rc.1", layout, []byte("rc"))
|
||||
fake.prerelease = true
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"dev build always sees update available",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("rel"))
|
||||
|
||||
u := newTestUpdater(fake, "dev", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.1.0", rel.Version)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestUpdater_Apply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("apply test exercises the unix swap path; windows has its own .old shuffle")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, u.Apply(context.Background(), rel))
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("new-binary"), got)
|
||||
|
||||
stat, err := os.Stat(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, stat.Mode().Perm()&0o100, "new binary should be executable")
|
||||
}
|
||||
|
||||
func TestUpdater_CheckLatest_SkipsUnsignedRelease(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
|
||||
fake.omitBundle = true
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable, "release without a sigstore bundle must be ignored")
|
||||
}
|
||||
|
||||
func TestUpdater_Apply_RejectsBadSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
u.Verifier = rejectAllVerifier{err: fmt.Errorf("test: signer identity mismatch")}
|
||||
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = u.Apply(context.Background(), rel)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sigstore")
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("old-binary"), got, "rejected signature must not touch the running binary")
|
||||
}
|
||||
|
||||
func TestUpdater_Apply_RejectsCorruptedArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
// Corrupt the archive without updating checksums.
|
||||
fake.archiveBytes = append(fake.archiveBytes, 0xff)
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = u.Apply(context.Background(), rel)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "checksum mismatch")
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("old-binary"), got, "corrupted update must not touch the running binary")
|
||||
}
|
||||
194
pkg/deviceagent/update/verify.go
Normal file
194
pkg/deviceagent/update/verify.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// 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 update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/sigstore/sigstore-go/pkg/bundle"
|
||||
"github.com/sigstore/sigstore-go/pkg/root"
|
||||
"github.com/sigstore/sigstore-go/pkg/tuf"
|
||||
"github.com/sigstore/sigstore-go/pkg/verify"
|
||||
)
|
||||
|
||||
const (
|
||||
// expectedSignerIssuer is the OIDC issuer Fulcio embeds in the
|
||||
// signing certificate when the workflow uses GitHub Actions'
|
||||
// OIDC token. This is the public-good Sigstore configuration.
|
||||
expectedSignerIssuer = "https://token.actions.githubusercontent.com"
|
||||
|
||||
// expectedWorkflowPath is the path of the release workflow that
|
||||
// is allowed to produce signed probo-agent artifacts. Anything
|
||||
// signed by a different workflow (or a workflow run outside of
|
||||
// a tagged commit) is rejected.
|
||||
expectedWorkflowPath = ".github/workflows/release-probo-agent.yaml"
|
||||
)
|
||||
|
||||
// Verifier verifies that a Sigstore bundle (`checksums.txt.bundle`)
|
||||
// attests an artifact (`checksums.txt`) was produced by the expected
|
||||
// signer identity. Implementations MUST hard-fail on any error;
|
||||
// callers do not interpret the error type.
|
||||
type Verifier interface {
|
||||
// Verify returns nil iff bundlePath is a valid Sigstore bundle
|
||||
// for the artifact at artifactPath, and the signer identity
|
||||
// matches the verifier's pinned issuer / SAN regex.
|
||||
Verify(ctx context.Context, artifactPath, bundlePath string) error
|
||||
}
|
||||
|
||||
// AllowAllVerifier accepts every input. It exists strictly for tests
|
||||
// of the surrounding download / extract pipeline. Production callers
|
||||
// must wire a real Verifier (e.g. CosignVerifier).
|
||||
type AllowAllVerifier struct{}
|
||||
|
||||
// Verify always returns nil.
|
||||
func (AllowAllVerifier) Verify(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// rejectAllVerifier is exposed for tests that need to assert Apply
|
||||
// hard-fails on signature problems.
|
||||
type rejectAllVerifier struct{ err error }
|
||||
|
||||
func (v rejectAllVerifier) Verify(_ context.Context, _, _ string) error { return v.err }
|
||||
|
||||
// CosignVerifier verifies cosign sign-blob bundles using sigstore-go
|
||||
// against the Sigstore public-good trust root.
|
||||
//
|
||||
// The verifier pins the signer identity to the probo-agent release
|
||||
// workflow on a tagged commit:
|
||||
//
|
||||
// issuer: https://token.actions.githubusercontent.com
|
||||
// SAN: https://github.com/<repo>/<workflow>@refs/tags/<tag-prefix><version>
|
||||
//
|
||||
// where <repo>, <workflow> and <tag-prefix> default to the values
|
||||
// hard-coded in the release pipeline. Both fields can be overridden
|
||||
// for testing or for repository forks.
|
||||
type CosignVerifier struct {
|
||||
Issuer string
|
||||
SANRegex string
|
||||
trustedRoot *root.TrustedRoot
|
||||
}
|
||||
|
||||
// CosignVerifierConfig configures a CosignVerifier.
|
||||
type CosignVerifierConfig struct {
|
||||
// Repo identifies the GitHub repository (e.g. "getprobo/probo").
|
||||
Repo string
|
||||
// WorkflowPath is the path within the repo to the workflow file
|
||||
// allowed to produce signed releases.
|
||||
WorkflowPath string
|
||||
// TagPrefix is the tag prefix the release workflow signs against
|
||||
// (e.g. "probo-agent/v"). The verifier matches anything after
|
||||
// this prefix.
|
||||
TagPrefix string
|
||||
// CacheDir is the on-disk directory used to cache the Sigstore
|
||||
// TUF metadata. Required.
|
||||
CacheDir string
|
||||
}
|
||||
|
||||
// NewCosignVerifier loads the Sigstore public-good trust root via
|
||||
// TUF (cached under cfg.CacheDir) and returns a Verifier that pins
|
||||
// signatures to the configured GitHub Actions workflow on a tagged
|
||||
// commit.
|
||||
func NewCosignVerifier(cfg CosignVerifierConfig) (*CosignVerifier, error) {
|
||||
if cfg.Repo == "" {
|
||||
return nil, fmt.Errorf("update: cosign verifier requires Repo")
|
||||
}
|
||||
if cfg.WorkflowPath == "" {
|
||||
cfg.WorkflowPath = expectedWorkflowPath
|
||||
}
|
||||
if cfg.TagPrefix == "" {
|
||||
cfg.TagPrefix = DefaultTagPrefix
|
||||
}
|
||||
if cfg.CacheDir == "" {
|
||||
return nil, fmt.Errorf("update: cosign verifier requires CacheDir")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.CacheDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("cannot create sigstore cache dir: %w", err)
|
||||
}
|
||||
|
||||
opts := tuf.DefaultOptions()
|
||||
opts.CachePath = cfg.CacheDir
|
||||
|
||||
tufClient, err := tuf.New(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot init sigstore TUF client: %w", err)
|
||||
}
|
||||
|
||||
trustedRoot, err := root.GetTrustedRoot(tufClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load sigstore trusted root: %w", err)
|
||||
}
|
||||
|
||||
sanRegex := buildSANRegex(cfg.Repo, cfg.WorkflowPath, cfg.TagPrefix)
|
||||
|
||||
return &CosignVerifier{
|
||||
Issuer: expectedSignerIssuer,
|
||||
SANRegex: sanRegex,
|
||||
trustedRoot: trustedRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify validates that bundlePath attests artifactPath was signed by
|
||||
// the expected GitHub Actions workflow on a tagged release.
|
||||
func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath string) error {
|
||||
b, err := bundle.LoadJSONFromPath(bundlePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
identity, err := verify.NewShortCertificateIdentity(v.Issuer, "", "", v.SANRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build signer identity: %w", err)
|
||||
}
|
||||
|
||||
sev, err := verify.NewVerifier(
|
||||
v.trustedRoot,
|
||||
verify.WithSignedCertificateTimestamps(1),
|
||||
verify.WithTransparencyLog(1),
|
||||
verify.WithObserverTimestamps(1),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build sigstore verifier: %w", err)
|
||||
}
|
||||
|
||||
artifact, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open artifact for verification: %w", err)
|
||||
}
|
||||
defer func() { _ = artifact.Close() }()
|
||||
|
||||
policy := verify.NewPolicy(
|
||||
verify.WithArtifact(artifact),
|
||||
verify.WithCertificateIdentity(identity),
|
||||
)
|
||||
|
||||
if _, err := sev.Verify(b, policy); err != nil {
|
||||
return fmt.Errorf("sigstore verification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSANRegex(repo, workflowPath, tagPrefix string) string {
|
||||
return `^https://github\.com/` +
|
||||
regexp.QuoteMeta(repo) +
|
||||
`/` +
|
||||
regexp.QuoteMeta(workflowPath) +
|
||||
`@refs/tags/` +
|
||||
regexp.QuoteMeta(tagPrefix) +
|
||||
`.+$`
|
||||
}
|
||||
Reference in New Issue
Block a user