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:
Bryan Frimin
2026-05-26 09:01:08 -07:00
parent e643a3259c
commit 22e50b3f11
56 changed files with 7410 additions and 127 deletions

583
pkg/deviceagent/agent.go Normal file
View 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()
}

View 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)
},
)
}

View 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
}

View 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")
}

View 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)
}

View 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 ""
}

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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]
}

View 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]
}

View 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]
}

View 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
}

View 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
}
}

View 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()
}

View 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
View 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
View 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
}

View 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"
}

View 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")
}

View 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)
},
)
}

View 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
}

View 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])
}

View 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
}

View 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
}

View 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 ""
}

View 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
}

View 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
}

View 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)
}

View 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)
},
)
}

View 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"
)

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
},
)
}

View 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) {}

View 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)
}

View 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)
}

View 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")
}

View 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) +
`.+$`
}