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:
583
pkg/deviceagent/agent.go
Normal file
583
pkg/deviceagent/agent.go
Normal file
@@ -0,0 +1,583 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/deviceagent/checks"
|
||||
"go.probo.inc/probo/pkg/deviceagent/update"
|
||||
)
|
||||
|
||||
const (
|
||||
hostInfoRefreshInterval = 6 * time.Hour
|
||||
perCheckTimeout = 15 * time.Second
|
||||
|
||||
pendingFlushBackoffMin = 15 * time.Second
|
||||
pendingFlushBackoffMax = 30 * time.Minute
|
||||
|
||||
updateCheckTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// ErrRestartRequired is returned by Agent.Run after a successful
|
||||
// in-place upgrade of the agent binary. Callers should exit cleanly
|
||||
// so the OS service supervisor relaunches the new binary.
|
||||
var ErrRestartRequired = errors.New("agent: restart required after self-update")
|
||||
|
||||
// Agent runs enrollment, heartbeat, and posture sync loops.
|
||||
type Agent struct {
|
||||
Dir string
|
||||
Version string
|
||||
UserAgent string
|
||||
Logger *log.Logger
|
||||
|
||||
// Updater performs binary self-update. When nil, auto-update is
|
||||
// disabled (e.g. dev builds, --no-auto-update at install time).
|
||||
Updater *update.Updater
|
||||
|
||||
cfg *Config
|
||||
client *Client
|
||||
revoked bool
|
||||
|
||||
collectHostInfo func() HostInfo
|
||||
hostInfo HostInfo
|
||||
hostInfoCollectedAt time.Time
|
||||
|
||||
now func() time.Time
|
||||
randInt63n func(int64) int64
|
||||
|
||||
pendingFlushBackoff time.Duration
|
||||
pendingFlushRetryAt time.Time
|
||||
}
|
||||
|
||||
// New creates an agent instance.
|
||||
func New(dir, version string, logger *log.Logger) *Agent {
|
||||
if logger == nil {
|
||||
logger = log.NewLogger(log.WithName("device-agent"))
|
||||
}
|
||||
return &Agent{
|
||||
Dir: dir,
|
||||
Version: version,
|
||||
UserAgent: fmt.Sprintf("probo-agent/%s", version),
|
||||
Logger: logger,
|
||||
collectHostInfo: func() HostInfo {
|
||||
return CollectHostInfo()
|
||||
},
|
||||
now: time.Now,
|
||||
randInt63n: func(n int64) int64 {
|
||||
return rand.Int63n(n)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnrollNewDevice enrolls and persists local config and key state.
|
||||
func (a *Agent) EnrollNewDevice(
|
||||
ctx context.Context,
|
||||
serverURL, enrollmentToken string,
|
||||
) (*EnrollResponse, error) {
|
||||
if serverURL == "" {
|
||||
return nil, errors.New("server URL is required")
|
||||
}
|
||||
if enrollmentToken == "" {
|
||||
return nil, errors.New("enrollment token is required")
|
||||
}
|
||||
|
||||
host := a.currentHostInfo(time.Now())
|
||||
client := NewClient(serverURL, "", a.UserAgent)
|
||||
|
||||
resp, err := client.Enroll(
|
||||
ctx,
|
||||
EnrollRequest{
|
||||
EnrollmentToken: enrollmentToken,
|
||||
HardwareUUID: host.HardwareUUID,
|
||||
SerialNumber: host.SerialNumber,
|
||||
Hostname: host.Hostname,
|
||||
Platform: host.Platform,
|
||||
OSVersion: host.OSVersion,
|
||||
AgentVersion: a.Version,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot enroll device: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: serverURL,
|
||||
DeviceID: resp.DeviceID,
|
||||
HeartbeatInterval: time.Duration(resp.HeartbeatSeconds) * time.Second,
|
||||
PostureInterval: time.Duration(resp.PostureSeconds) * time.Second,
|
||||
}
|
||||
if err := SaveConfig(a.Dir, cfg); err != nil {
|
||||
return nil, fmt.Errorf("cannot save config: %w", err)
|
||||
}
|
||||
if err := SaveAPIKey(a.Dir, resp.APIKey); err != nil {
|
||||
return nil, fmt.Errorf("cannot save api key: %w", err)
|
||||
}
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.Warn("cannot clear pending posture queue after enrollment", log.Error(err))
|
||||
}
|
||||
|
||||
a.cfg = cfg
|
||||
a.client = NewClient(serverURL, resp.APIKey, a.UserAgent)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// LoadLocalState loads persisted config and API key.
|
||||
func (a *Agent) LoadLocalState() error {
|
||||
cfg, err := LoadConfig(a.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := LoadAPIKey(a.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("config has no server URL")
|
||||
}
|
||||
a.cfg = cfg
|
||||
a.client = NewClient(cfg.ServerURL, key, a.UserAgent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the long-running heartbeat and posture loops. It returns
|
||||
// ErrRestartRequired after a successful self-update so the caller can
|
||||
// exit cleanly and let the service supervisor restart the new binary.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if a.cfg == nil || a.client == nil {
|
||||
if err := a.LoadLocalState(); err != nil {
|
||||
return fmt.Errorf("cannot load agent state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
a.Logger = a.Logger.With(log.String("device_id", a.cfg.DeviceID))
|
||||
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent starting",
|
||||
log.String("server", a.cfg.ServerURL),
|
||||
log.Duration("heartbeat_interval", a.cfg.HeartbeatInterval),
|
||||
log.Duration("posture_interval", a.cfg.PostureInterval),
|
||||
log.Duration("host_info_refresh_interval", hostInfoRefreshInterval),
|
||||
log.Bool("auto_update_enabled", a.autoUpdateEnabled()),
|
||||
log.Duration("update_interval", a.cfg.UpdateInterval),
|
||||
)
|
||||
|
||||
_, _ = a.doHeartbeat(ctx)
|
||||
a.doPostures(ctx)
|
||||
|
||||
heartbeatTicker := time.NewTicker(a.cfg.HeartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
postureTicker := time.NewTicker(a.cfg.PostureInterval)
|
||||
defer postureTicker.Stop()
|
||||
|
||||
updateTicker, updateChan := a.newUpdateTicker()
|
||||
if updateTicker != nil {
|
||||
defer updateTicker.Stop()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.Logger.InfoCtx(ctx, "agent stopping", log.Error(ctx.Err()))
|
||||
return ctx.Err()
|
||||
case <-heartbeatTicker.C:
|
||||
heartbeatIntervalChanged, postureIntervalChanged := a.doHeartbeat(ctx)
|
||||
if heartbeatIntervalChanged {
|
||||
heartbeatTicker.Reset(a.cfg.HeartbeatInterval)
|
||||
}
|
||||
if postureIntervalChanged {
|
||||
postureTicker.Reset(a.cfg.PostureInterval)
|
||||
}
|
||||
case <-postureTicker.C:
|
||||
a.doPostures(ctx)
|
||||
case <-updateChan:
|
||||
if a.tryAutoUpdate(ctx) {
|
||||
return ErrRestartRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// autoUpdateEnabled reports whether the agent should periodically
|
||||
// self-update. Disabled when no Updater is wired in or the operator
|
||||
// flipped UpdatesDisabled in config.
|
||||
func (a *Agent) autoUpdateEnabled() bool {
|
||||
if a.cfg == nil {
|
||||
return false
|
||||
}
|
||||
if a.cfg.UpdatesDisabled {
|
||||
return false
|
||||
}
|
||||
return a.Updater != nil
|
||||
}
|
||||
|
||||
// newUpdateTicker returns the periodic ticker used to drive
|
||||
// auto-update checks. When auto-update is disabled we return a
|
||||
// (nil, nil) channel pair so the select in Run never fires.
|
||||
func (a *Agent) newUpdateTicker() (*time.Ticker, <-chan time.Time) {
|
||||
if !a.autoUpdateEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
t := time.NewTicker(a.cfg.UpdateInterval)
|
||||
return t, t.C
|
||||
}
|
||||
|
||||
// tryAutoUpdate runs one auto-update cycle and returns true when the
|
||||
// agent binary was successfully replaced and the process should
|
||||
// restart.
|
||||
func (a *Agent) tryAutoUpdate(parent context.Context) bool {
|
||||
if !a.autoUpdateEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, updateCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
rel, err := a.Updater.CheckLatest(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, update.ErrNoUpdateAvailable) {
|
||||
a.Logger.DebugCtx(ctx, "no agent update available")
|
||||
return false
|
||||
}
|
||||
a.Logger.WarnCtx(ctx, "agent update check failed", log.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent update available, applying",
|
||||
log.String("from_version", a.Version),
|
||||
log.String("to_version", rel.Version),
|
||||
)
|
||||
|
||||
if err := a.Updater.Apply(ctx, rel); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot apply agent update", log.Error(err), log.String("to_version", rel.Version))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CollectOnce executes checks without pushing results to the server.
|
||||
func (a *Agent) CollectOnce(ctx context.Context) []checks.Result {
|
||||
now := time.Now()
|
||||
results := make([]checks.Result, 0)
|
||||
for _, c := range checks.All() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return results
|
||||
default:
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, perCheckTimeout)
|
||||
r := c.Run(checkCtx)
|
||||
cancel()
|
||||
if r.ObservedAt.IsZero() {
|
||||
r.ObservedAt = now
|
||||
}
|
||||
if r.CheckKey == "" {
|
||||
r.CheckKey = c.Key()
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Unenroll revokes server state best-effort and clears local credentials.
|
||||
func (a *Agent) Unenroll(ctx context.Context) error {
|
||||
if a.cfg == nil || a.client == nil {
|
||||
if err := a.LoadLocalState(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.client.Unenroll(ctx); err != nil {
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"unenroll server-side revocation failed, continuing with local wipe",
|
||||
log.Error(err),
|
||||
)
|
||||
}
|
||||
if err := DeleteAPIKey(a.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) {
|
||||
if a.revoked {
|
||||
return false, false
|
||||
}
|
||||
|
||||
oldHeartbeatInterval := a.cfg.HeartbeatInterval
|
||||
oldPostureInterval := a.cfg.PostureInterval
|
||||
|
||||
host := a.currentHostInfo(time.Now())
|
||||
resp, err := a.client.Heartbeat(
|
||||
ctx,
|
||||
HeartbeatRequest{
|
||||
AgentVersion: a.Version,
|
||||
Hostname: host.Hostname,
|
||||
OSVersion: host.OSVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err))
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
if resp.HeartbeatSeconds > 0 {
|
||||
next := normalizeHeartbeatInterval(time.Duration(resp.HeartbeatSeconds) * time.Second)
|
||||
if next != a.cfg.HeartbeatInterval {
|
||||
a.cfg.HeartbeatInterval = next
|
||||
}
|
||||
}
|
||||
if resp.PostureSeconds > 0 {
|
||||
next := normalizePostureInterval(time.Duration(resp.PostureSeconds) * time.Second)
|
||||
if next != a.cfg.PostureInterval {
|
||||
a.cfg.PostureInterval = next
|
||||
}
|
||||
}
|
||||
a.flushQueuedPostures(ctx)
|
||||
|
||||
heartbeatChanged := a.cfg.HeartbeatInterval != oldHeartbeatInterval
|
||||
postureChanged := a.cfg.PostureInterval != oldPostureInterval
|
||||
if heartbeatChanged || postureChanged {
|
||||
if err := SaveConfig(a.Dir, a.cfg); err != nil {
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"cannot persist updated agent intervals",
|
||||
log.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return heartbeatChanged, postureChanged
|
||||
}
|
||||
|
||||
func (a *Agent) doPostures(ctx context.Context) {
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
results := a.CollectOnce(ctx)
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
var (
|
||||
passCount int
|
||||
failCount int
|
||||
unknownCount int
|
||||
notApplicableCount int
|
||||
)
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case checks.StatusPass:
|
||||
passCount++
|
||||
case checks.StatusFail:
|
||||
failCount++
|
||||
case checks.StatusUnknown:
|
||||
unknownCount++
|
||||
case checks.StatusNotApplicable:
|
||||
notApplicableCount++
|
||||
}
|
||||
}
|
||||
a.Logger.InfoCtx(
|
||||
ctx,
|
||||
"posture checks completed",
|
||||
log.Int("checks", len(results)),
|
||||
log.Int("pass_count", passCount),
|
||||
log.Int("fail_count", failCount),
|
||||
log.Int("unknown_count", unknownCount),
|
||||
log.Int("not_applicable_count", notApplicableCount),
|
||||
log.Duration("elapsed", time.Since(start)),
|
||||
log.Duration("per_check_timeout", perCheckTimeout),
|
||||
)
|
||||
|
||||
payload := make([]PostureResultPayload, 0, len(results))
|
||||
for _, r := range results {
|
||||
payload = append(
|
||||
payload,
|
||||
PostureResultPayload{
|
||||
CheckKey: r.CheckKey,
|
||||
Status: string(r.Status),
|
||||
Evidence: checks.EvidenceJSON(r.Evidence),
|
||||
ObservedAt: r.ObservedAt,
|
||||
},
|
||||
)
|
||||
}
|
||||
a.flushQueuedPostures(ctx)
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
if err := a.client.PushPostures(ctx, payload); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "posture push failed", log.Error(err))
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
return
|
||||
}
|
||||
dropped, enqueueErr := enqueuePendingPostureBatch(a.Dir, payload, a.currentTime())
|
||||
if enqueueErr != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot queue posture batch after failed push", log.Error(enqueueErr))
|
||||
return
|
||||
}
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"queued posture batch for retry",
|
||||
log.Int("queued_results", len(payload)),
|
||||
log.Int("dropped_old_batches", dropped),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) flushQueuedPostures(ctx context.Context) {
|
||||
if a.revoked || a.client == nil {
|
||||
return
|
||||
}
|
||||
now := a.currentTime()
|
||||
if !a.pendingFlushRetryAt.IsZero() && now.Before(a.pendingFlushRetryAt) {
|
||||
return
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(a.Dir)
|
||||
if err != nil {
|
||||
a.Logger.WarnCtx(ctx, "cannot load pending posture batches", log.Error(err))
|
||||
return
|
||||
}
|
||||
if len(batches) == 0 {
|
||||
a.resetPendingFlushRetry()
|
||||
return
|
||||
}
|
||||
|
||||
for i, batch := range batches {
|
||||
if err := a.client.PushPostures(ctx, batch.Results); err != nil {
|
||||
if IsUnauthorized(err) {
|
||||
a.handleUnauthorized()
|
||||
return
|
||||
}
|
||||
if saveErr := savePendingPostureBatches(a.Dir, batches[i:]); saveErr != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot persist pending posture batches", log.Error(saveErr))
|
||||
}
|
||||
retryIn := a.schedulePendingFlushRetry(now)
|
||||
a.Logger.WarnCtx(
|
||||
ctx,
|
||||
"cannot flush pending posture batch",
|
||||
log.Error(err),
|
||||
log.Int("remaining_batches", len(batches)-i),
|
||||
log.Duration("retry_in", retryIn),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.ErrorCtx(ctx, "cannot clear pending posture batches", log.Error(err))
|
||||
return
|
||||
}
|
||||
a.resetPendingFlushRetry()
|
||||
a.Logger.InfoCtx(ctx, "flushed pending posture batches", log.Int("batches", len(batches)))
|
||||
}
|
||||
|
||||
func (a *Agent) currentHostInfo(now time.Time) HostInfo {
|
||||
if a.hostInfoCollectedAt.IsZero() || now.Sub(a.hostInfoCollectedAt) >= hostInfoRefreshInterval {
|
||||
collector := a.collectHostInfo
|
||||
if collector == nil {
|
||||
collector = CollectHostInfo
|
||||
}
|
||||
a.hostInfo = collector()
|
||||
a.hostInfoCollectedAt = now
|
||||
}
|
||||
return a.hostInfo
|
||||
}
|
||||
|
||||
func (a *Agent) currentTime() time.Time {
|
||||
if a.now != nil {
|
||||
return a.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (a *Agent) randomInt63n(n int64) int64 {
|
||||
if n <= 1 {
|
||||
return 0
|
||||
}
|
||||
if a.randInt63n != nil {
|
||||
return a.randInt63n(n)
|
||||
}
|
||||
return rand.Int63n(n)
|
||||
}
|
||||
|
||||
func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration {
|
||||
nextBase := a.pendingFlushBackoff
|
||||
if nextBase <= 0 {
|
||||
nextBase = pendingFlushBackoffMin
|
||||
} else {
|
||||
nextBase *= 2
|
||||
if nextBase > pendingFlushBackoffMax {
|
||||
nextBase = pendingFlushBackoffMax
|
||||
}
|
||||
}
|
||||
a.pendingFlushBackoff = nextBase
|
||||
|
||||
jitterRange := nextBase / 5
|
||||
jitter := time.Duration(0)
|
||||
if jitterRange > 0 {
|
||||
jitter = time.Duration(a.randomInt63n(int64(jitterRange)*2+1)) - jitterRange
|
||||
}
|
||||
|
||||
retryIn := nextBase + jitter
|
||||
if retryIn < time.Second {
|
||||
retryIn = time.Second
|
||||
}
|
||||
a.pendingFlushRetryAt = now.Add(retryIn)
|
||||
return retryIn
|
||||
}
|
||||
|
||||
func (a *Agent) resetPendingFlushRetry() {
|
||||
a.pendingFlushBackoff = 0
|
||||
a.pendingFlushRetryAt = time.Time{}
|
||||
}
|
||||
|
||||
// handleUnauthorized wipes local auth state after a 401 response.
|
||||
func (a *Agent) handleUnauthorized() {
|
||||
if a.revoked {
|
||||
return
|
||||
}
|
||||
a.revoked = true
|
||||
if a.client != nil {
|
||||
a.client.APIKey = ""
|
||||
}
|
||||
|
||||
a.Logger.Warn("agent API returned 401, wiping local key and requiring re-enrollment")
|
||||
if err := DeleteAPIKey(a.Dir); err != nil {
|
||||
a.Logger.Error("cannot delete local key after 401", log.Error(err))
|
||||
}
|
||||
if err := clearPendingPostureBatches(a.Dir); err != nil {
|
||||
a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err))
|
||||
}
|
||||
a.resetPendingFlushRetry()
|
||||
}
|
||||
71
pkg/deviceagent/agent_test.go
Normal file
71
pkg/deviceagent/agent_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgent_currentHostInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"reuses cached host info before refresh interval",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
count := 0
|
||||
a := &Agent{
|
||||
collectHostInfo: func() HostInfo {
|
||||
count++
|
||||
return HostInfo{Hostname: fmt.Sprintf("host-%d", count)}
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Unix(1_000, 0)
|
||||
first := a.currentHostInfo(now)
|
||||
second := a.currentHostInfo(now.Add(2 * time.Hour))
|
||||
|
||||
assert.Equal(t, 1, count)
|
||||
assert.Equal(t, first, second)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"refreshes host info when cache expires",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
count := 0
|
||||
a := &Agent{
|
||||
collectHostInfo: func() HostInfo {
|
||||
count++
|
||||
return HostInfo{Hostname: fmt.Sprintf("host-%d", count)}
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Unix(2_000, 0)
|
||||
first := a.currentHostInfo(now)
|
||||
second := a.currentHostInfo(now.Add(hostInfoRefreshInterval + time.Minute))
|
||||
|
||||
assert.Equal(t, 2, count)
|
||||
assert.NotEqual(t, first, second)
|
||||
},
|
||||
)
|
||||
}
|
||||
34
pkg/deviceagent/checks/check.go
Normal file
34
pkg/deviceagent/checks/check.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is the outcome of one posture check on the current host.
|
||||
type Result struct {
|
||||
CheckKey string
|
||||
Status Status
|
||||
Evidence map[string]any
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
// Check runs a single posture check.
|
||||
type Check interface {
|
||||
Key() string
|
||||
Run(ctx context.Context) Result
|
||||
}
|
||||
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
440
pkg/deviceagent/checks/checks_darwin.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, darwinDiskEncryption)
|
||||
Register(KeyScreenLock, darwinScreenLock)
|
||||
Register(KeyFirewallEnabled, darwinFirewall)
|
||||
Register(KeyTimeSync, darwinTimeSync)
|
||||
Register(KeyOSVersion, darwinOSVersion)
|
||||
Register(KeyAutoUpdate, darwinAutoUpdate)
|
||||
Register(KeyPasswordPolicy, darwinPasswordPolicy)
|
||||
Register(KeyRemoteLogin, darwinRemoteLogin)
|
||||
Register(KeyMalwareProtection, darwinMalwareProtection)
|
||||
}
|
||||
|
||||
func darwinDiskEncryption(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "fdesetup", "status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("sysadminctl") {
|
||||
status := RunCommand(ctx, "sysadminctl", "-screenLock", "status", "-password", "-")
|
||||
rawCombined := strings.TrimSpace(status.Stdout + "\n" + status.Stderr)
|
||||
ev := map[string]any{
|
||||
"backend": "sysadminctl",
|
||||
"raw": rawCombined,
|
||||
"raw_stdout": status.Stdout,
|
||||
"raw_stderr": status.Stderr,
|
||||
}
|
||||
mode, seconds, ok := darwinScreenLockMode(rawCombined)
|
||||
if ok {
|
||||
ev["mode"] = mode
|
||||
if mode == "seconds" && seconds >= 0 {
|
||||
ev["seconds"] = seconds
|
||||
}
|
||||
if mode == "immediate" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if status.Err != nil {
|
||||
ev["error"] = status.Err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword")
|
||||
ev := map[string]any{}
|
||||
if askSource != "" {
|
||||
ev["source"] = askSource
|
||||
}
|
||||
if ask.Err != nil {
|
||||
if darwinDefaultsMissing(ask) {
|
||||
ev["ask_for_password"] = "0"
|
||||
ev["note"] = "askForPassword is unset or unavailable"
|
||||
return fail(ev)
|
||||
}
|
||||
ev["error"] = ask.Err.Error()
|
||||
ev["stderr"] = ask.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
enabled := strings.TrimSpace(ask.Stdout) == "1"
|
||||
|
||||
delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay")
|
||||
ev["ask_for_password"] = ask.Stdout
|
||||
if delayCmd.Err == nil {
|
||||
ev["ask_for_password_delay"] = delayCmd.Stdout
|
||||
if delaySource != "" && delaySource != askSource {
|
||||
ev["delay_source"] = delaySource
|
||||
}
|
||||
}
|
||||
if enabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinScreenLockMode(raw string) (string, int, bool) {
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, "immediate") {
|
||||
return "immediate", 0, true
|
||||
}
|
||||
if strings.Contains(lower, "off") {
|
||||
return "off", -1, true
|
||||
}
|
||||
if idx := strings.Index(lower, "seconds"); idx >= 0 {
|
||||
prefix := strings.Fields(lower[:idx])
|
||||
if len(prefix) == 0 {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
n, err := strconv.Atoi(prefix[len(prefix)-1])
|
||||
if err != nil {
|
||||
return "seconds", -1, true
|
||||
}
|
||||
return "seconds", n, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func darwinFirewall(ctx context.Context) Result {
|
||||
out := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.alf",
|
||||
"globalstate",
|
||||
)
|
||||
if out.Err == nil {
|
||||
state := strings.TrimSpace(out.Stdout)
|
||||
ev := map[string]any{"backend": "defaults", "global_state": state}
|
||||
if state == "1" || state == "2" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
ev := map[string]any{
|
||||
"backend": "socketfilterfw",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(out.Err),
|
||||
"defaults_stderr": out.Stderr,
|
||||
}
|
||||
if fallback.Err != nil {
|
||||
ev["error"] = fallback.Err.Error()
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
if darwinStateIndicatesEnabled(fallback.Stdout) {
|
||||
return pass(ev)
|
||||
}
|
||||
if darwinStateIndicatesDisabled(fallback.Stdout) {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// darwinReadScreenSaverDefault prefers console-user settings when running as root.
|
||||
func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, string) {
|
||||
consoleUser := darwinConsoleUser(ctx)
|
||||
if os.Geteuid() == 0 && consoleUser != "" {
|
||||
var consoleMissing CmdResult
|
||||
consoleMissingSource := ""
|
||||
|
||||
if CommandExists("sudo") {
|
||||
consoleUserCurrentHost := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"-currentHost",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserCurrentHost.Err == nil {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserCurrentHost) {
|
||||
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserCurrentHost
|
||||
consoleMissingSource = "console_user_current_host:" + consoleUser
|
||||
}
|
||||
|
||||
consoleUserDomain := RunCommand(
|
||||
ctx,
|
||||
"sudo",
|
||||
"-u",
|
||||
consoleUser,
|
||||
"defaults",
|
||||
"read",
|
||||
"com.apple.screensaver",
|
||||
key,
|
||||
)
|
||||
if consoleUserDomain.Err == nil {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserDomain) {
|
||||
return consoleUserDomain, "console_user:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserDomain
|
||||
consoleMissingSource = "console_user:" + consoleUser
|
||||
}
|
||||
}
|
||||
|
||||
plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist"
|
||||
consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key)
|
||||
if consoleUserOut.Err == nil {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if !darwinDefaultsMissing(consoleUserOut) {
|
||||
return consoleUserOut, "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource == "" {
|
||||
consoleMissing = consoleUserOut
|
||||
consoleMissingSource = "console_user_plist:" + consoleUser
|
||||
}
|
||||
if consoleMissingSource != "" {
|
||||
return consoleMissing, consoleMissingSource
|
||||
}
|
||||
}
|
||||
|
||||
currentHost := RunCommand(ctx, "defaults", "-currentHost", "read", "com.apple.screensaver", key)
|
||||
if currentHost.Err == nil {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
|
||||
currentUser := RunCommand(ctx, "defaults", "read", "com.apple.screensaver", key)
|
||||
if currentUser.Err == nil {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
if !darwinDefaultsMissing(currentUser) {
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
if !darwinDefaultsMissing(currentHost) {
|
||||
return currentHost, "current_user_current_host"
|
||||
}
|
||||
return currentUser, "current_user"
|
||||
}
|
||||
|
||||
func darwinDefaultsMissing(out CmdResult) bool {
|
||||
lower := strings.ToLower(out.Stderr + "\n" + out.Stdout)
|
||||
return strings.Contains(lower, "does not exist") ||
|
||||
strings.Contains(lower, "could not find") ||
|
||||
strings.Contains(lower, "does not exist in domain")
|
||||
}
|
||||
|
||||
func darwinConsoleUser(ctx context.Context) string {
|
||||
if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" {
|
||||
return sudoUser
|
||||
}
|
||||
out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console")
|
||||
if out.Err != nil {
|
||||
return ""
|
||||
}
|
||||
user := strings.TrimSpace(out.Stdout)
|
||||
if user == "" || user == "root" || user == "loginwindow" {
|
||||
return ""
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func darwinStateIndicatesEnabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "enabled") ||
|
||||
strings.Contains(lower, "state = 1") ||
|
||||
strings.Contains(lower, "state = 2")
|
||||
}
|
||||
|
||||
func darwinStateIndicatesDisabled(raw string) bool {
|
||||
lower := strings.ToLower(raw)
|
||||
return strings.Contains(lower, "disabled") || strings.Contains(lower, "state = 0")
|
||||
}
|
||||
|
||||
func darwinTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getusingnetworktime")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.Contains(strings.ToLower(out.Stdout), "on")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "sw_vers", "-productVersion")
|
||||
if out.Err != nil || out.Stdout == "" {
|
||||
return unknown(map[string]any{"error": "sw_vers failed"})
|
||||
}
|
||||
build := RunCommand(ctx, "sw_vers", "-buildVersion")
|
||||
ev := map[string]any{
|
||||
"product_version": out.Stdout,
|
||||
"build_version": build.Stdout,
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinAutoUpdate(ctx context.Context) Result {
|
||||
primary := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
"/Library/Preferences/com.apple.SoftwareUpdate",
|
||||
"AutomaticCheckEnabled",
|
||||
)
|
||||
if primary.Err == nil {
|
||||
ev := map[string]any{
|
||||
"backend": "defaults",
|
||||
"automatic_check_enabled": primary.Stdout,
|
||||
}
|
||||
if strings.TrimSpace(primary.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "softwareupdate", "--schedule")
|
||||
ev := map[string]any{
|
||||
"backend": "softwareupdate",
|
||||
"raw": fallback.Stdout,
|
||||
"defaults_error": errString(primary.Err),
|
||||
"defaults_stderr": primary.Stderr,
|
||||
}
|
||||
if fallback.Err != nil ||
|
||||
needsAdmin(fallback.Stdout) ||
|
||||
needsAdmin(fallback.Stderr) {
|
||||
ev["error"] = errString(fallback.Err)
|
||||
ev["stderr"] = fallback.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(fallback.Stdout)
|
||||
switch {
|
||||
case strings.Contains(lower, "is turned on"),
|
||||
strings.Contains(lower, "automatic check is on"):
|
||||
return pass(ev)
|
||||
case strings.Contains(lower, "is turned off"),
|
||||
strings.Contains(lower, "automatic check is off"):
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func darwinPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "pwpolicy", "-getaccountpolicies")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(lower, "no account policies") || lower == "" {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func darwinRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "systemsetup", "-getremotelogin")
|
||||
if out.Err != nil || needsAdmin(out.Stdout) {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"raw": out.Stdout,
|
||||
"error": errString(out.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
off := strings.Contains(strings.ToLower(out.Stdout), "off")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if off {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func darwinMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []string{
|
||||
"/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
"/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
ev := map[string]any{"engine": "XProtect", "plist": path}
|
||||
version := RunCommand(
|
||||
ctx,
|
||||
"defaults",
|
||||
"read",
|
||||
strings.TrimSuffix(path, ".plist"),
|
||||
"Version",
|
||||
)
|
||||
if version.Err == nil {
|
||||
ev["version"] = version.Stdout
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(
|
||||
map[string]any{
|
||||
"engine": "XProtect",
|
||||
"note": "XProtect.meta.plist not found in expected locations",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// needsAdmin checks systemsetup's stdout for privilege errors.
|
||||
func needsAdmin(stdout string) bool {
|
||||
return strings.Contains(strings.ToLower(stdout), "administrator access")
|
||||
}
|
||||
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
147
pkg/deviceagent/checks/checks_freebsd.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, freebsdDiskEncryption)
|
||||
Register(KeyScreenLock, freebsdScreenLock)
|
||||
Register(KeyFirewallEnabled, freebsdFirewall)
|
||||
Register(KeyTimeSync, freebsdTimeSync)
|
||||
Register(KeyOSVersion, freebsdOSVersion)
|
||||
Register(KeyAutoUpdate, freebsdAutoUpdate)
|
||||
Register(KeyPasswordPolicy, freebsdPasswordPolicy)
|
||||
Register(KeyRemoteLogin, freebsdRemoteLogin)
|
||||
Register(KeyMalwareProtection, freebsdMalwareProtection)
|
||||
}
|
||||
|
||||
func freebsdDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("geli") {
|
||||
return unknown(map[string]any{"note": "geli command not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "geli", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "ACTIVE") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdScreenLock(ctx context.Context) Result {
|
||||
if CommandExists("xscreensaver-command") {
|
||||
out := RunCommand(ctx, "xscreensaver-command", "-version")
|
||||
if out.Err == nil {
|
||||
return pass(map[string]any{"raw": out.Stdout})
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD does not have a unified screen lock policy",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdFirewall(ctx context.Context) Result {
|
||||
if !CommandExists("pfctl") {
|
||||
return unknown(map[string]any{"note": "pfctl not found"})
|
||||
}
|
||||
out := RunCommand(ctx, "pfctl", "-si")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "Status: Enabled") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "ntpd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr}
|
||||
if out.Err != nil {
|
||||
return fail(ev)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdOSVersion(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "uname", "-r")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
return pass(map[string]any{"release": out.Stdout})
|
||||
}
|
||||
|
||||
func freebsdAutoUpdate(ctx context.Context) Result {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "FreeBSD relies on operator-driven freebsd-update",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func freebsdPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.conf")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
hasPolicy := strings.Contains(body, "minpasswordlen=") ||
|
||||
strings.Contains(body, "passwordtime=")
|
||||
ev := map[string]any{
|
||||
"login_conf_snippet": truncate(body, 400),
|
||||
}
|
||||
if hasPolicy {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdMalwareProtection(ctx context.Context) Result {
|
||||
if !CommandExists("clamd") && !CommandExists("clamdscan") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "clamav not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "service", "clamav_clamd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func freebsdRemoteLogin(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "service", "sshd", "status")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if strings.Contains(strings.ToLower(out.Stdout), "is running") {
|
||||
return fail(ev)
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
399
pkg/deviceagent/checks/checks_linux.go
Normal file
399
pkg/deviceagent/checks/checks_linux.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, linuxDiskEncryption)
|
||||
Register(KeyScreenLock, linuxScreenLock)
|
||||
Register(KeyFirewallEnabled, linuxFirewall)
|
||||
Register(KeyTimeSync, linuxTimeSync)
|
||||
Register(KeyOSVersion, linuxOSVersion)
|
||||
Register(KeyAutoUpdate, linuxAutoUpdate)
|
||||
Register(KeyPasswordPolicy, linuxPasswordPolicy)
|
||||
Register(KeyRemoteLogin, linuxRemoteLogin)
|
||||
Register(KeyMalwareProtection, linuxMalwareProtection)
|
||||
}
|
||||
|
||||
func linuxDiskEncryption(ctx context.Context) Result {
|
||||
ev := map[string]any{}
|
||||
|
||||
if data, err := os.ReadFile("/etc/crypttab"); err == nil {
|
||||
body := strings.TrimSpace(string(data))
|
||||
ev["crypttab_present"] = true
|
||||
ev["crypttab_lines"] = nonCommentLines(body)
|
||||
if len(nonCommentLines(body)) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
} else {
|
||||
ev["crypttab_present"] = false
|
||||
}
|
||||
|
||||
lsblk := RunCommand(ctx, "lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-r")
|
||||
if lsblk.Err == nil {
|
||||
ev["lsblk"] = truncate(lsblk.Stdout, 800)
|
||||
lines := strings.Split(lsblk.Stdout, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "crypt" {
|
||||
return pass(ev)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ev["lsblk_error"] = lsblk.Err.Error()
|
||||
}
|
||||
|
||||
if lsblk.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxScreenLock(ctx context.Context) Result {
|
||||
if !CommandExists("gsettings") {
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "gsettings not installed (likely headless host)",
|
||||
},
|
||||
)
|
||||
}
|
||||
idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled")
|
||||
if idle.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": idle.Err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
on := strings.TrimSpace(idle.Stdout) == "true"
|
||||
ev := map[string]any{"lock_enabled": idle.Stdout}
|
||||
if on {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxFirewall(ctx context.Context) Result {
|
||||
if CommandExists("ufw") {
|
||||
out := RunCommand(ctx, "ufw", "status")
|
||||
if out.Err == nil {
|
||||
active := strings.Contains(strings.ToLower(out.Stdout), "status: active")
|
||||
ev := map[string]any{"backend": "ufw", "raw": out.Stdout}
|
||||
if active {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
if CommandExists("firewall-cmd") {
|
||||
out := RunCommand(ctx, "firewall-cmd", "--state")
|
||||
ev := map[string]any{"backend": "firewalld", "raw": out.Stdout}
|
||||
if out.Err == nil && strings.Contains(strings.ToLower(out.Stdout), "running") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("nft") {
|
||||
out := RunCommand(ctx, "nft", "list", "ruleset")
|
||||
ev := map[string]any{
|
||||
"backend": "nftables",
|
||||
"rules_excerpt": truncate(out.Stdout, 400),
|
||||
}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
if strings.Contains(out.Stdout, "chain ") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
if CommandExists("iptables") {
|
||||
out := RunCommand(ctx, "iptables", "-S", "INPUT")
|
||||
ev := map[string]any{"backend": "iptables"}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
policy, rules := parseIptablesInput(out.Stdout)
|
||||
ev["input_policy"] = policy
|
||||
ev["input_rules"] = rules
|
||||
if policy == "DROP" || policy == "REJECT" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
if rules == 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
// ACCEPT policy with some rules means the operator is filtering,
|
||||
// but we cannot tell from -S whether the rules are restrictive
|
||||
// or permissive without modelling the chain.
|
||||
return unknown(ev)
|
||||
}
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "no known firewall tool found",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// parseIptablesInput extracts the INPUT chain policy and rule count from
|
||||
// `iptables -S INPUT` output.
|
||||
func parseIptablesInput(s string) (string, int) {
|
||||
var (
|
||||
policy string
|
||||
rules int
|
||||
)
|
||||
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-P INPUT"):
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
policy = strings.ToUpper(fields[2])
|
||||
}
|
||||
case strings.HasPrefix(line, "-A INPUT"):
|
||||
rules++
|
||||
}
|
||||
}
|
||||
|
||||
return policy, rules
|
||||
}
|
||||
|
||||
func linuxTimeSync(ctx context.Context) Result {
|
||||
if !CommandExists("timedatectl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "timedatectl not installed",
|
||||
},
|
||||
)
|
||||
}
|
||||
out := RunCommand(ctx, "timedatectl", "show")
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
if strings.Contains(out.Stdout, "NTPSynchronized=yes") {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxOSVersion(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"pretty_name": kvLookup(body, "PRETTY_NAME"),
|
||||
"version_id": kvLookup(body, "VERSION_ID"),
|
||||
"id": kvLookup(body, "ID"),
|
||||
}
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
func linuxAutoUpdate(ctx context.Context) Result {
|
||||
if _, err := os.Stat("/etc/apt/apt.conf.d/20auto-upgrades"); err == nil {
|
||||
data, _ := os.ReadFile("/etc/apt/apt.conf.d/20auto-upgrades")
|
||||
body := string(data)
|
||||
ev := map[string]any{
|
||||
"backend": "unattended-upgrades",
|
||||
"raw": body,
|
||||
}
|
||||
if strings.Contains(body, `"1"`) {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
if CommandExists("systemctl") {
|
||||
out := RunCommand(ctx, "systemctl", "is-enabled", "dnf-automatic.timer")
|
||||
if out.Err == nil {
|
||||
ev := map[string]any{"backend": "dnf-automatic", "state": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "enabled" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
return notApplicable(
|
||||
map[string]any{
|
||||
"note": "no known auto-update mechanism",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func linuxPasswordPolicy(ctx context.Context) Result {
|
||||
data, err := os.ReadFile("/etc/login.defs")
|
||||
if err != nil {
|
||||
return unknown(map[string]any{"error": err.Error()})
|
||||
}
|
||||
body := string(data)
|
||||
minLen := loginDefsLookup(body, "PASS_MIN_LEN")
|
||||
maxDays := loginDefsLookup(body, "PASS_MAX_DAYS")
|
||||
ev := map[string]any{
|
||||
"pass_min_len": minLen,
|
||||
"pass_max_days": maxDays,
|
||||
}
|
||||
if minLen == "" {
|
||||
ev["parse_error"] = "PASS_MIN_LEN not set"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
minLenValue, err := strconv.Atoi(minLen)
|
||||
if err != nil {
|
||||
ev["parse_error"] = "invalid PASS_MIN_LEN value"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if minLenValue >= 8 {
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
ev["pass_min_len_value"] = minLenValue
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func linuxRemoteLogin(ctx context.Context) Result {
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(map[string]any{"note": "systemctl unavailable"})
|
||||
}
|
||||
state := RunCommand(ctx, "systemctl", "is-active", "ssh.service")
|
||||
stateAlt := RunCommand(ctx, "systemctl", "is-active", "sshd.service")
|
||||
merged := strings.TrimSpace(state.Stdout)
|
||||
if merged == "" {
|
||||
merged = strings.TrimSpace(stateAlt.Stdout)
|
||||
}
|
||||
ev := map[string]any{"is_active": merged}
|
||||
switch merged {
|
||||
case "active":
|
||||
return fail(ev)
|
||||
case "inactive", "failed":
|
||||
return pass(ev)
|
||||
case "":
|
||||
return notApplicable(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
// linuxMalwareProtection tracks AV/EDR agent services, not MAC frameworks.
|
||||
func linuxMalwareProtection(ctx context.Context) Result {
|
||||
candidates := []struct {
|
||||
unit string
|
||||
name string
|
||||
}{
|
||||
{"clamav-daemon.service", "ClamAV"},
|
||||
{"clamd.service", "ClamAV"},
|
||||
{"clamd@scan.service", "ClamAV"},
|
||||
{"falcon-sensor.service", "CrowdStrike Falcon"},
|
||||
{"sentinelone.service", "SentinelOne"},
|
||||
{"sentineld.service", "SentinelOne"},
|
||||
{"sav-protect.service", "Sophos"},
|
||||
{"sophos-spl.service", "Sophos"},
|
||||
{"esets.service", "ESET"},
|
||||
{"mdatp.service", "Microsoft Defender for Endpoint"},
|
||||
{"wazuh-agent.service", "Wazuh"},
|
||||
{"ossec.service", "OSSEC"},
|
||||
{"elastic-agent.service", "Elastic Agent"},
|
||||
{"osqueryd.service", "osquery"},
|
||||
}
|
||||
|
||||
if !CommandExists("systemctl") {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"note": "systemctl not available; cannot enumerate endpoint agents",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var active, installed []string
|
||||
for _, c := range candidates {
|
||||
state := strings.TrimSpace(
|
||||
RunCommand(ctx, "systemctl", "is-active", c.unit).Stdout)
|
||||
switch state {
|
||||
case "active":
|
||||
active = append(active, c.name)
|
||||
case "inactive", "failed", "activating", "deactivating":
|
||||
installed = append(installed, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"active": active,
|
||||
"installed": installed,
|
||||
}
|
||||
if len(active) > 0 {
|
||||
return pass(ev)
|
||||
}
|
||||
if len(installed) > 0 {
|
||||
return fail(ev)
|
||||
}
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
func nonCommentLines(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kvLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line[:eq]) == key {
|
||||
v := strings.TrimSpace(line[eq+1:])
|
||||
v = strings.Trim(v, `"`)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loginDefsLookup(body, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == key {
|
||||
return fields[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
421
pkg/deviceagent/checks/checks_windows.go
Normal file
421
pkg/deviceagent/checks/checks_windows.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(KeyDiskEncryption, windowsDiskEncryption)
|
||||
Register(KeyScreenLock, windowsScreenLock)
|
||||
Register(KeyFirewallEnabled, windowsFirewall)
|
||||
Register(KeyTimeSync, windowsTimeSync)
|
||||
Register(KeyOSVersion, windowsOSVersion)
|
||||
Register(KeyAutoUpdate, windowsAutoUpdate)
|
||||
Register(KeyPasswordPolicy, windowsPasswordPolicy)
|
||||
Register(KeyRemoteLogin, windowsRemoteLogin)
|
||||
Register(KeyMalwareProtection, windowsMalwareProtection)
|
||||
}
|
||||
|
||||
const psNoProfile = "-NoProfile"
|
||||
|
||||
func powershell(ctx context.Context, script string) CmdResult {
|
||||
return RunCommand(ctx, "powershell.exe", psNoProfile, "-Command", script)
|
||||
}
|
||||
|
||||
func windowsDiskEncryption(ctx context.Context) Result {
|
||||
if !CommandExists("manage-bde.exe") && !CommandExists("manage-bde") {
|
||||
return unknown(map[string]any{"note": "manage-bde not found"})
|
||||
}
|
||||
|
||||
out := RunCommand(ctx, "manage-bde", "-status")
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 600)}
|
||||
if out.Err != nil {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "percentage encrypted: 100") ||
|
||||
strings.Contains(lower, "fully encrypted") ||
|
||||
strings.Contains(lower, "protection on") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsScreenLock(ctx context.Context) Result {
|
||||
// HKCU resolves to the SYSTEM hive when the agent runs as LocalSystem,
|
||||
// so we first look for a machine-wide policy and then enumerate every
|
||||
// loaded interactive user hive under HKU.
|
||||
machine := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop' `+
|
||||
`-ErrorAction SilentlyContinue).ScreenSaverIsSecure`,
|
||||
)
|
||||
if machine.Err == nil {
|
||||
v := strings.TrimSpace(machine.Stdout)
|
||||
if v != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "machine_policy",
|
||||
"screen_saver_is_secure": v,
|
||||
}
|
||||
if v == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
}
|
||||
|
||||
users := powershell(
|
||||
ctx,
|
||||
`Get-ChildItem 'Registry::HKEY_USERS' | `+
|
||||
`Where-Object { $_.PSChildName -match '^S-1-5-21-' } | `+
|
||||
`ForEach-Object { `+
|
||||
` $path = "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop"; `+
|
||||
` $key = Get-ItemProperty $path -ErrorAction SilentlyContinue; `+
|
||||
` "$($_.PSChildName)=$($key.ScreenSaverIsSecure)" `+
|
||||
`}`,
|
||||
)
|
||||
if users.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"error": users.Err.Error(),
|
||||
"stderr": users.Stderr,
|
||||
"machine_policy_error": errString(machine.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{
|
||||
"backend": "hkey_users",
|
||||
"raw": truncate(users.Stdout, 400),
|
||||
}
|
||||
users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout)
|
||||
ev["users"] = users_
|
||||
if len(users_) == 0 {
|
||||
ev["note"] = "no interactive user hives loaded"
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if anyEnabled && !anyDisabled {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsUserScreenLock parses one "SID=<value>" line per user from
|
||||
// the registry enumeration and reports whether each user has screen
|
||||
// saver locking enabled.
|
||||
func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) {
|
||||
users := map[string]string{}
|
||||
var anyEnabled, anyDisabled bool
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(line, "=")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sid := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
users[sid] = value
|
||||
switch value {
|
||||
case "1":
|
||||
anyEnabled = true
|
||||
default:
|
||||
anyDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
return users, anyDisabled, anyEnabled
|
||||
}
|
||||
|
||||
func windowsFirewall(ctx context.Context) Result {
|
||||
primary := powershell(
|
||||
ctx,
|
||||
`(Get-NetFirewallProfile -PolicyStore ActiveStore | `+
|
||||
`Sort-Object Name | `+
|
||||
`ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join ";"`,
|
||||
)
|
||||
if primary.Err == nil && strings.TrimSpace(primary.Stdout) != "" {
|
||||
ev := map[string]any{
|
||||
"backend": "Get-NetFirewallProfile",
|
||||
"raw": primary.Stdout,
|
||||
}
|
||||
profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout)
|
||||
ev["profiles"] = profiles
|
||||
if allEnabled {
|
||||
return pass(ev)
|
||||
}
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
fallback := RunCommand(ctx, "netsh", "advfirewall", "show", "allprofiles", "state")
|
||||
if fallback.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": errString(fallback.Err),
|
||||
"stderr": fallback.Stderr,
|
||||
"powershell_error": errString(primary.Err),
|
||||
},
|
||||
)
|
||||
}
|
||||
ev := map[string]any{
|
||||
"backend": "netsh",
|
||||
"raw": truncate(fallback.Stdout, 600),
|
||||
}
|
||||
stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout)
|
||||
ev["state_lines"] = stateLines
|
||||
if len(stateLines) > 0 && !anyOff {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// parseWindowsFirewallProfiles parses "Domain=True;Private=True;Public=True"
|
||||
// from Get-NetFirewallProfile output, returning per-profile state and
|
||||
// whether every profile is enabled.
|
||||
func parseWindowsFirewallProfiles(s string) (map[string]string, bool) {
|
||||
profiles := map[string]string{}
|
||||
allEnabled := true
|
||||
any := false
|
||||
for _, profile := range strings.Split(s, ";") {
|
||||
parts := strings.SplitN(strings.TrimSpace(profile), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
profiles[name] = value
|
||||
any = true
|
||||
if !strings.EqualFold(value, "true") {
|
||||
allEnabled = false
|
||||
}
|
||||
}
|
||||
return profiles, any && allEnabled
|
||||
}
|
||||
|
||||
// parseNetshFirewallStates extracts per-profile "State <ON|OFF>" lines
|
||||
// from `netsh advfirewall show allprofiles state`. It is whitespace- and
|
||||
// case-insensitive.
|
||||
func parseNetshFirewallStates(s string) ([]string, bool) {
|
||||
var states []string
|
||||
anyOff := false
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if !strings.HasPrefix(lower, "state") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(lower)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
value := fields[len(fields)-1]
|
||||
states = append(states, value)
|
||||
if value != "on" {
|
||||
anyOff = true
|
||||
}
|
||||
}
|
||||
|
||||
return states, anyOff
|
||||
}
|
||||
|
||||
func windowsTimeSync(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "w32tm", "/query", "/status")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsOSVersion(ctx context.Context) Result {
|
||||
out := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Version`)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`)
|
||||
return pass(
|
||||
map[string]any{
|
||||
"version": out.Stdout,
|
||||
"caption": caption.Stdout,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func windowsAutoUpdate(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$au = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `+
|
||||
`-ErrorAction SilentlyContinue; `+
|
||||
`"$($au.NoAutoUpdate);$($au.AUOptions)"`,
|
||||
)
|
||||
ev := map[string]any{}
|
||||
if out.Err != nil {
|
||||
ev["error"] = out.Err.Error()
|
||||
ev["stderr"] = out.Stderr
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2)
|
||||
var noAutoUpdate, auOptions string
|
||||
if len(parts) >= 1 {
|
||||
noAutoUpdate = strings.TrimSpace(parts[0])
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
auOptions = strings.TrimSpace(parts[1])
|
||||
}
|
||||
ev["no_auto_update"] = noAutoUpdate
|
||||
ev["au_options"] = auOptions
|
||||
|
||||
// NoAutoUpdate=1 explicitly disables automatic updates via policy.
|
||||
if noAutoUpdate == "1" {
|
||||
return fail(ev)
|
||||
}
|
||||
// AUOptions semantics:
|
||||
// 2 — notify before download (no auto-install)
|
||||
// 3 — auto download, prompt to install
|
||||
// 4 — auto download + auto install (target SOC posture)
|
||||
// 5 — managed by local administrators
|
||||
switch auOptions {
|
||||
case "3", "4", "5":
|
||||
return pass(ev)
|
||||
case "2":
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
// No managed policy. The Windows Update service must at least be
|
||||
// running for the OS default of auto-install to take effect.
|
||||
svc := RunCommand(ctx, "sc.exe", "query", "wuauserv")
|
||||
if svc.Err != nil {
|
||||
ev["wuauserv_error"] = svc.Err.Error()
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
if strings.Contains(svc.Stdout, "RUNNING") {
|
||||
ev["wuauserv"] = "running"
|
||||
return pass(ev)
|
||||
}
|
||||
ev["wuauserv"] = "stopped"
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsPasswordPolicy(ctx context.Context) Result {
|
||||
out := RunCommand(ctx, "net", "accounts")
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
||||
lower := strings.ToLower(out.Stdout)
|
||||
if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsMalwareProtection(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`$s = Get-MpComputerStatus; `+
|
||||
`"$($s.AntivirusEnabled);$($s.RealTimeProtectionEnabled);`+
|
||||
`$($s.AMServiceEnabled);$($s.AntivirusSignatureLastUpdated)"`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(
|
||||
map[string]any{
|
||||
"error": out.Err.Error(),
|
||||
"stderr": out.Stderr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
parts := strings.Split(out.Stdout, ";")
|
||||
ev := map[string]any{"raw": out.Stdout}
|
||||
if len(parts) < 3 {
|
||||
return unknown(ev)
|
||||
}
|
||||
|
||||
antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True")
|
||||
realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True")
|
||||
serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True")
|
||||
ev["antivirus_enabled"] = antivirusOn
|
||||
ev["real_time_protection"] = realtimeOn
|
||||
ev["am_service_enabled"] = serviceOn
|
||||
if len(parts) >= 4 {
|
||||
ev["signatures_last_updated"] = strings.TrimSpace(parts[3])
|
||||
}
|
||||
|
||||
if antivirusOn && (realtimeOn || serviceOn) {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
|
||||
func windowsRemoteLogin(ctx context.Context) Result {
|
||||
out := powershell(
|
||||
ctx,
|
||||
`(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server').fDenyTSConnections`,
|
||||
)
|
||||
if out.Err != nil {
|
||||
return unknown(map[string]any{"error": out.Err.Error()})
|
||||
}
|
||||
|
||||
ev := map[string]any{"fdeny_ts_connections": out.Stdout}
|
||||
if strings.TrimSpace(out.Stdout) == "1" {
|
||||
return pass(ev)
|
||||
}
|
||||
|
||||
return fail(ev)
|
||||
}
|
||||
31
pkg/deviceagent/checks/evidence.go
Normal file
31
pkg/deviceagent/checks/evidence.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// EvidenceJSON encodes evidence for transport to the agent API.
|
||||
func EvidenceJSON(ev map[string]any) json.RawMessage {
|
||||
if len(ev) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
55
pkg/deviceagent/checks/registry.go
Normal file
55
pkg/deviceagent/checks/registry.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
registryMu sync.Mutex
|
||||
registry []Check
|
||||
)
|
||||
|
||||
// Register adds a check implementation to the process registry.
|
||||
func Register(key string, run func(context.Context) Result) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(
|
||||
registry,
|
||||
funcCheck{
|
||||
key: key,
|
||||
run: run,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// All returns a stable snapshot of registered checks.
|
||||
func All() []Check {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
out := make([]Check, len(registry))
|
||||
copy(out, registry)
|
||||
sort.SliceStable(
|
||||
out,
|
||||
func(i, j int) bool {
|
||||
return out[i].Key() < out[j].Key()
|
||||
},
|
||||
)
|
||||
return out
|
||||
}
|
||||
101
pkg/deviceagent/checks/runcmd.go
Normal file
101
pkg/deviceagent/checks/runcmd.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultCommandTimeout = 5 * time.Second
|
||||
|
||||
var commandExistsCache sync.Map
|
||||
|
||||
// CmdResult captures the basic outcome of an OS subcommand.
|
||||
type CmdResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// RunCommand executes a command and returns trimmed stdout/stderr.
|
||||
func RunCommand(ctx context.Context, name string, args ...string) CmdResult {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, defaultCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
resolved, ok := resolveCommandPath(name)
|
||||
if !ok {
|
||||
return CmdResult{
|
||||
Err: fmt.Errorf("command %q not available at expected absolute path", name),
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, resolved, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return CmdResult{
|
||||
Stdout: strings.TrimSpace(stdout.String()),
|
||||
Stderr: strings.TrimSpace(stderr.String()),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// CommandExists reports whether `cmd` exists at expected absolute path(s).
|
||||
func CommandExists(cmd string) bool {
|
||||
if cached, ok := commandExistsCache.Load(cmd); ok {
|
||||
return cached.(bool)
|
||||
}
|
||||
|
||||
_, exists := resolveCommandPath(cmd)
|
||||
commandExistsCache.Store(cmd, exists)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func resolveCommandPath(cmd string) (string, bool) {
|
||||
if filepath.IsAbs(cmd) {
|
||||
return cmd, isExecutableFile(cmd)
|
||||
}
|
||||
|
||||
for _, candidate := range commandCandidates(cmd) {
|
||||
if isExecutableFile(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return true
|
||||
}
|
||||
|
||||
return info.Mode().Perm()&0o111 != 0
|
||||
}
|
||||
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
31
pkg/deviceagent/checks/runcmd_paths_darwin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var darwinCommandPaths = map[string][]string{
|
||||
"defaults": {"/usr/bin/defaults"},
|
||||
"fdesetup": {"/usr/bin/fdesetup"},
|
||||
"pwpolicy": {"/usr/bin/pwpolicy"},
|
||||
"softwareupdate": {"/usr/sbin/softwareupdate"},
|
||||
"stat": {"/usr/bin/stat"},
|
||||
"sudo": {"/usr/bin/sudo"},
|
||||
"sw_vers": {"/usr/bin/sw_vers"},
|
||||
"sysadminctl": {"/usr/sbin/sysadminctl"},
|
||||
"systemsetup": {"/usr/sbin/systemsetup"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return darwinCommandPaths[cmd]
|
||||
}
|
||||
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
29
pkg/deviceagent/checks/runcmd_paths_freebsd.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var freeBSDCommandPaths = map[string][]string{
|
||||
"clamd": {"/usr/local/sbin/clamd", "/usr/sbin/clamd"},
|
||||
"clamdscan": {"/usr/local/bin/clamdscan", "/usr/bin/clamdscan"},
|
||||
"geli": {"/sbin/geli"},
|
||||
"pfctl": {"/sbin/pfctl"},
|
||||
"service": {"/usr/sbin/service"},
|
||||
"uname": {"/usr/bin/uname"},
|
||||
"xscreensaver-command": {"/usr/local/bin/xscreensaver-command"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return freeBSDCommandPaths[cmd]
|
||||
}
|
||||
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
30
pkg/deviceagent/checks/runcmd_paths_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
var linuxCommandPaths = map[string][]string{
|
||||
"firewall-cmd": {"/usr/bin/firewall-cmd"},
|
||||
"gsettings": {"/usr/bin/gsettings"},
|
||||
"iptables": {"/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables"},
|
||||
"lsblk": {"/usr/bin/lsblk", "/bin/lsblk"},
|
||||
"nft": {"/usr/sbin/nft", "/sbin/nft"},
|
||||
"systemctl": {"/usr/bin/systemctl", "/bin/systemctl"},
|
||||
"timedatectl": {"/usr/bin/timedatectl", "/bin/timedatectl"},
|
||||
"ufw": {"/usr/sbin/ufw", "/sbin/ufw"},
|
||||
}
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
return linuxCommandPaths[cmd]
|
||||
}
|
||||
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
21
pkg/deviceagent/checks/runcmd_paths_other.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build !darwin && !linux && !freebsd && !windows
|
||||
|
||||
package checks
|
||||
|
||||
func commandCandidates(_ string) []string {
|
||||
return nil
|
||||
}
|
||||
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
48
pkg/deviceagent/checks/runcmd_paths_windows.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func commandCandidates(cmd string) []string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
system32 := filepath.Join(systemRoot, "System32")
|
||||
|
||||
switch strings.ToLower(cmd) {
|
||||
case "powershell", "powershell.exe":
|
||||
return []string{
|
||||
filepath.Join(system32, "WindowsPowerShell", "v1.0", "powershell.exe"),
|
||||
}
|
||||
case "manage-bde", "manage-bde.exe":
|
||||
return []string{filepath.Join(system32, "manage-bde.exe")}
|
||||
case "netsh", "netsh.exe":
|
||||
return []string{filepath.Join(system32, "netsh.exe")}
|
||||
case "w32tm", "w32tm.exe":
|
||||
return []string{filepath.Join(system32, "w32tm.exe")}
|
||||
case "sc", "sc.exe":
|
||||
return []string{filepath.Join(system32, "sc.exe")}
|
||||
case "net", "net.exe":
|
||||
return []string{filepath.Join(system32, "net.exe")}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
84
pkg/deviceagent/checks/shared.go
Normal file
84
pkg/deviceagent/checks/shared.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check keys shared across OS implementations.
|
||||
const (
|
||||
KeyDiskEncryption = "DISK_ENCRYPTION"
|
||||
KeyScreenLock = "SCREEN_LOCK"
|
||||
KeyFirewallEnabled = "FIREWALL_ENABLED"
|
||||
KeyTimeSync = "TIME_SYNC"
|
||||
KeyOSVersion = "OS_VERSION"
|
||||
KeyAutoUpdate = "AUTO_UPDATE"
|
||||
KeyPasswordPolicy = "PASSWORD_POLICY"
|
||||
KeyRemoteLogin = "REMOTE_LOGIN"
|
||||
KeyMalwareProtection = "MALWARE_PROTECTION"
|
||||
)
|
||||
|
||||
type funcCheck struct {
|
||||
key string
|
||||
run func(ctx context.Context) Result
|
||||
}
|
||||
|
||||
func (c funcCheck) Key() string { return c.key }
|
||||
|
||||
func (c funcCheck) Run(ctx context.Context) Result {
|
||||
r := c.run(ctx)
|
||||
if r.CheckKey == "" {
|
||||
r.CheckKey = c.key
|
||||
}
|
||||
if r.ObservedAt.IsZero() {
|
||||
r.ObservedAt = time.Now().UTC()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func pass(ev map[string]any) Result {
|
||||
return Result{Status: StatusPass, Evidence: ev}
|
||||
}
|
||||
|
||||
func fail(ev map[string]any) Result {
|
||||
return Result{Status: StatusFail, Evidence: ev}
|
||||
}
|
||||
|
||||
func unknown(ev map[string]any) Result {
|
||||
return Result{Status: StatusUnknown, Evidence: ev}
|
||||
}
|
||||
|
||||
func notApplicable(ev map[string]any) Result {
|
||||
return Result{Status: StatusNotApplicable, Evidence: ev}
|
||||
}
|
||||
|
||||
// truncate limits oversized evidence values.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// errString returns "" for a nil error.
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
24
pkg/deviceagent/checks/status.go
Normal file
24
pkg/deviceagent/checks/status.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
package checks
|
||||
|
||||
// Status is the posture status sent to the agent API.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "PASS"
|
||||
StatusFail Status = "FAIL"
|
||||
StatusUnknown Status = "UNKNOWN"
|
||||
StatusNotApplicable Status = "NOT_APPLICABLE"
|
||||
)
|
||||
234
pkg/deviceagent/client.go
Normal file
234
pkg/deviceagent/client.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
)
|
||||
|
||||
type (
|
||||
// Client calls the /api/agent/v1 REST API.
|
||||
Client struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
UserAgent string
|
||||
HTTP *http.Client
|
||||
}
|
||||
)
|
||||
|
||||
// NewClient creates an API client.
|
||||
func NewClient(serverURL, apiKey, userAgent string) *Client {
|
||||
httpClient := httpclient.DefaultPooledClient()
|
||||
httpClient.Timeout = 30 * time.Second
|
||||
|
||||
return &Client{
|
||||
ServerURL: strings.TrimRight(serverURL, "/"),
|
||||
APIKey: apiKey,
|
||||
UserAgent: userAgent,
|
||||
HTTP: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
EnrollRequest struct {
|
||||
EnrollmentToken string `json:"enrollment_token"`
|
||||
HardwareUUID string `json:"hardware_uuid"`
|
||||
SerialNumber *string `json:"serial_number,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
Platform string `json:"platform"`
|
||||
OSVersion string `json:"os_version"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
}
|
||||
|
||||
EnrollResponse struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
||||
PostureSeconds int `json:"posture_interval_seconds"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
HeartbeatRequest struct {
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
UptimeSec int64 `json:"uptime_seconds,omitempty"`
|
||||
}
|
||||
|
||||
HeartbeatResponse struct {
|
||||
HeartbeatSeconds int `json:"heartbeat_interval_seconds"`
|
||||
PostureSeconds int `json:"posture_interval_seconds"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
PostureResultPayload struct {
|
||||
CheckKey string `json:"check_key"`
|
||||
Status string `json:"status"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
PosturesRequest struct {
|
||||
Results []PostureResultPayload `json:"results"`
|
||||
}
|
||||
)
|
||||
|
||||
// Enroll exchanges an enrollment token for a device key.
|
||||
func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) {
|
||||
var resp EnrollResponse
|
||||
if err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/enroll",
|
||||
false,
|
||||
req,
|
||||
&resp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Heartbeat sends a periodic device heartbeat.
|
||||
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
||||
var resp HeartbeatResponse
|
||||
if err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/heartbeat",
|
||||
true,
|
||||
req,
|
||||
&resp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PushPostures sends posture check results.
|
||||
func (c *Client) PushPostures(ctx context.Context, results []PostureResultPayload) error {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/postures",
|
||||
true,
|
||||
PosturesRequest{Results: results},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Unenroll asks the server to revoke the device.
|
||||
func (c *Client) Unenroll(ctx context.Context) error {
|
||||
return c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/agent/v1/unenroll",
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// HTTPError captures a non-2xx API response.
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return fmt.Sprintf("agent api: %d %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// IsUnauthorized reports whether err is an API 401.
|
||||
func IsUnauthorized(err error) bool {
|
||||
var herr *HTTPError
|
||||
if !errors.As(err, &herr) {
|
||||
return false
|
||||
}
|
||||
return herr.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) do(
|
||||
ctx context.Context,
|
||||
method, path string,
|
||||
authed bool,
|
||||
in any,
|
||||
out any,
|
||||
) error {
|
||||
url := c.ServerURL + path
|
||||
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
buf, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal request: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
|
||||
if authed {
|
||||
if c.APIKey == "" {
|
||||
return errors.New("agent client: no api key set")
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot perform request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
buf, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(buf))}
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("cannot decode response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
145
pkg/deviceagent/config.go
Normal file
145
pkg/deviceagent/config.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Package deviceagent implements the probo host agent.
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConfigFileName stores persisted agent config.
|
||||
ConfigFileName = "config.json"
|
||||
|
||||
// DefaultHeartbeatInterval is the default heartbeat cadence.
|
||||
DefaultHeartbeatInterval = 5 * time.Minute
|
||||
// MinHeartbeatInterval is the minimum heartbeat cadence.
|
||||
MinHeartbeatInterval = 1 * time.Minute
|
||||
|
||||
// DefaultPostureInterval is the default posture cadence.
|
||||
DefaultPostureInterval = 1 * time.Hour
|
||||
// MinPostureInterval is the minimum posture cadence.
|
||||
MinPostureInterval = 15 * time.Minute
|
||||
|
||||
// DefaultUpdateInterval is the default cadence at which the
|
||||
// agent checks for new releases.
|
||||
DefaultUpdateInterval = 4 * time.Hour
|
||||
// MinUpdateInterval is the floor used when a smaller value is
|
||||
// configured. Updates are network and disk heavy, so we cap
|
||||
// frequency to once per hour.
|
||||
MinUpdateInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
// Config is the persisted agent configuration.
|
||||
Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
HeartbeatInterval time.Duration `json:"heartbeat_interval,omitempty"`
|
||||
PostureInterval time.Duration `json:"posture_interval,omitempty"`
|
||||
UpdateInterval time.Duration `json:"update_interval,omitempty"`
|
||||
UpdatesDisabled bool `json:"updates_disabled,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// ConfigPath returns the absolute path to the agent's config file.
|
||||
func ConfigPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
return filepath.Join(dir, ConfigFileName)
|
||||
}
|
||||
|
||||
// LoadConfig reads config from disk.
|
||||
func LoadConfig(dir string) (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigPath(dir))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read config: %w", err)
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode config: %w", err)
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes config to disk with mode 0600.
|
||||
func SaveConfig(dir string, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("nil config")
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create config dir: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encode config: %w", err)
|
||||
}
|
||||
|
||||
path := ConfigPath(dir)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
c.HeartbeatInterval = normalizeHeartbeatInterval(c.HeartbeatInterval)
|
||||
c.PostureInterval = normalizePostureInterval(c.PostureInterval)
|
||||
c.UpdateInterval = normalizeUpdateInterval(c.UpdateInterval)
|
||||
}
|
||||
|
||||
func normalizeHeartbeatInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultHeartbeatInterval, MinHeartbeatInterval)
|
||||
}
|
||||
|
||||
func normalizePostureInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultPostureInterval, MinPostureInterval)
|
||||
}
|
||||
|
||||
func normalizeUpdateInterval(v time.Duration) time.Duration {
|
||||
return normalizeInterval(v, DefaultUpdateInterval, MinUpdateInterval)
|
||||
}
|
||||
|
||||
func normalizeInterval(v, fallback, floor time.Duration) time.Duration {
|
||||
if v <= 0 {
|
||||
v = fallback
|
||||
}
|
||||
|
||||
if v < floor {
|
||||
return floor
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
23
pkg/deviceagent/config_paths_other.go
Normal file
23
pkg/deviceagent/config_paths_other.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package deviceagent
|
||||
|
||||
// DefaultConfigDir returns the directory under which the agent's config
|
||||
// and keystore live on non-Windows hosts.
|
||||
func DefaultConfigDir() string {
|
||||
return "/var/lib/probo-agent"
|
||||
}
|
||||
31
pkg/deviceagent/config_paths_windows.go
Normal file
31
pkg/deviceagent/config_paths_windows.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultConfigDir returns the directory under which the agent's config
|
||||
// and keystore live on Windows.
|
||||
func DefaultConfigDir() string {
|
||||
programData := os.Getenv("ProgramData")
|
||||
if programData == "" {
|
||||
programData = `C:\ProgramData`
|
||||
}
|
||||
|
||||
return filepath.Join(programData, "Probo", "agent")
|
||||
}
|
||||
71
pkg/deviceagent/config_test.go
Normal file
71
pkg/deviceagent/config_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfig_applyDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"uses defaults when unset",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, DefaultHeartbeatInterval, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, DefaultPostureInterval, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"clamps values below minimum floors",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
HeartbeatInterval: 10 * time.Second,
|
||||
PostureInterval: 1 * time.Minute,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, MinHeartbeatInterval, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, MinPostureInterval, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps values above floors",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{
|
||||
HeartbeatInterval: 3 * time.Minute,
|
||||
PostureInterval: 2 * time.Hour,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
|
||||
assert.Equal(t, 3*time.Minute, cfg.HeartbeatInterval)
|
||||
assert.Equal(t, 2*time.Hour, cfg.PostureInterval)
|
||||
},
|
||||
)
|
||||
}
|
||||
98
pkg/deviceagent/hostinfo.go
Normal file
98
pkg/deviceagent/hostinfo.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// HostInfo is the device identity reported by the agent.
|
||||
HostInfo struct {
|
||||
Hostname string
|
||||
Platform string
|
||||
OSVersion string
|
||||
HardwareUUID string
|
||||
SerialNumber *string
|
||||
}
|
||||
)
|
||||
|
||||
// CollectHostInfo gathers host identity using best-effort probes.
|
||||
func CollectHostInfo() HostInfo {
|
||||
info := HostInfo{
|
||||
Platform: platformString(),
|
||||
}
|
||||
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
info.Hostname = h
|
||||
}
|
||||
|
||||
if info.Hostname == "" {
|
||||
info.Hostname = "unknown-host"
|
||||
}
|
||||
|
||||
info.OSVersion = collectOSVersion()
|
||||
info.HardwareUUID = collectHardwareUUID()
|
||||
if sn := collectSerialNumber(); sn != "" {
|
||||
info.SerialNumber = &sn
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// hashFallbackUUID derives a stable fallback from hostname and MAC.
|
||||
func hashFallbackUUID() string {
|
||||
hostname, _ := os.Hostname()
|
||||
mac := firstStableMAC()
|
||||
h := sha256.New()
|
||||
h.Write([]byte(hostname))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(mac))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func firstStableMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ifc.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
return ifc.HardwareAddr.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// runQuiet runs a command and returns trimmed stdout.
|
||||
func runQuiet(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
92
pkg/deviceagent/hostinfo_darwin.go
Normal file
92
pkg/deviceagent/hostinfo_darwin.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "DARWIN"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "sw_vers", "-productVersion")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice")
|
||||
if uuid := extractValue(out, "IOPlatformUUID"); uuid != "" {
|
||||
return uuid
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice")
|
||||
return extractValue(out, "IOPlatformSerialNumber")
|
||||
}
|
||||
|
||||
// extractValue parses ioreg key/value output.
|
||||
func extractValue(s, key string) string {
|
||||
idx := strings.Index(s, "\""+key+"\"")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := s[idx:]
|
||||
eq := strings.Index(rest, "=")
|
||||
if eq < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest = strings.TrimSpace(rest[eq+1:])
|
||||
rest = strings.TrimPrefix(rest, "<")
|
||||
rest = strings.TrimPrefix(rest, ">")
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
rest = rest[1:]
|
||||
end := strings.Index(rest, "\"")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
|
||||
end := strings.IndexAny(rest, "\r\n")
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
57
pkg/deviceagent/hostinfo_freebsd.go
Normal file
57
pkg/deviceagent/hostinfo_freebsd.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "FREEBSD"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-r")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "kenv", "smbios.system.uuid")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "kenv", "smbios.system.serial")
|
||||
return out
|
||||
}
|
||||
93
pkg/deviceagent/hostinfo_linux.go
Normal file
93
pkg/deviceagent/hostinfo_linux.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "LINUX"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
if prettyName := parseOSReleasePrettyName(data); prettyName != "" {
|
||||
return prettyName
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
for _, path := range []string{
|
||||
"/sys/class/dmi/id/product_uuid",
|
||||
"/etc/machine-id",
|
||||
"/var/lib/dbus/machine-id",
|
||||
} {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if s := strings.TrimSpace(string(data)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
if data, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOSReleasePrettyName(data []byte) string {
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if k, v, ok := splitKV(line); ok {
|
||||
if k == "PRETTY_NAME" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitKV(line string) (string, string, bool) {
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
k := strings.TrimSpace(line[:eq])
|
||||
v := strings.TrimSpace(line[eq+1:])
|
||||
v = strings.Trim(v, `"`)
|
||||
|
||||
return k, v, true
|
||||
}
|
||||
44
pkg/deviceagent/hostinfo_other.go
Normal file
44
pkg/deviceagent/hostinfo_other.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build !darwin && !linux && !freebsd && !windows
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return strings.ToUpper(runtime.GOOS)
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
return ""
|
||||
}
|
||||
70
pkg/deviceagent/hostinfo_windows.go
Normal file
70
pkg/deviceagent/hostinfo_windows.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func platformString() string {
|
||||
return "WINDOWS"
|
||||
}
|
||||
|
||||
func collectOSVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(ctx, "cmd", "/C", "ver")
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
out, _ = runQuiet(ctx, "uname", "-sr")
|
||||
return out
|
||||
}
|
||||
|
||||
func collectHardwareUUID() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// `wmic` is deprecated; `Get-CimInstance` requires PowerShell.
|
||||
out, _ := runQuiet(
|
||||
ctx,
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystemProduct).UUID",
|
||||
)
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
|
||||
return hashFallbackUUID()
|
||||
}
|
||||
|
||||
func collectSerialNumber() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, _ := runQuiet(
|
||||
ctx,
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_BIOS).SerialNumber",
|
||||
)
|
||||
return out
|
||||
}
|
||||
84
pkg/deviceagent/keystore.go
Normal file
84
pkg/deviceagent/keystore.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyFileName stores the device API key on disk.
|
||||
const KeyFileName = "agent.key"
|
||||
|
||||
// ErrKeyNotFound is returned when no key file exists.
|
||||
var ErrKeyNotFound = errors.New("agent key not found")
|
||||
|
||||
// KeyPath returns the absolute path of the device API key file.
|
||||
func KeyPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
return filepath.Join(dir, KeyFileName)
|
||||
}
|
||||
|
||||
// SaveAPIKey writes the API key to disk with mode 0600.
|
||||
func SaveAPIKey(dir, key string) error {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create keystore dir: %w", err)
|
||||
}
|
||||
|
||||
path := KeyPath(dir)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(strings.TrimSpace(key)+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAPIKey reads the API key from disk.
|
||||
func LoadAPIKey(dir string) (string, error) {
|
||||
data, err := os.ReadFile(KeyPath(dir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cannot read agent key: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// DeleteAPIKey removes the API key file.
|
||||
func DeleteAPIKey(dir string) error {
|
||||
if err := os.Remove(KeyPath(dir)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot delete agent key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
144
pkg/deviceagent/posture_queue.go
Normal file
144
pkg/deviceagent/posture_queue.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pendingPosturesFileName = "pending-postures.json"
|
||||
maxPendingPostureBatches = 96
|
||||
)
|
||||
|
||||
type pendingPostureBatch struct {
|
||||
QueuedAt time.Time `json:"queued_at"`
|
||||
Results []PostureResultPayload `json:"results"`
|
||||
}
|
||||
|
||||
func pendingPosturesPath(dir string) string {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
return filepath.Join(dir, pendingPosturesFileName)
|
||||
}
|
||||
|
||||
func loadPendingPostureBatches(dir string) ([]pendingPostureBatch, error) {
|
||||
data, err := os.ReadFile(pendingPosturesPath(dir))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot read pending postures: %w", err)
|
||||
}
|
||||
|
||||
var batches []pendingPostureBatch
|
||||
if err := json.Unmarshal(data, &batches); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pending postures: %w", err)
|
||||
}
|
||||
|
||||
filtered := make([]pendingPostureBatch, 0, len(batches))
|
||||
for _, batch := range batches {
|
||||
if len(batch.Results) == 0 {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, batch)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func savePendingPostureBatches(dir string, batches []pendingPostureBatch) error {
|
||||
if dir == "" {
|
||||
dir = DefaultConfigDir()
|
||||
}
|
||||
|
||||
path := pendingPosturesPath(dir)
|
||||
if len(batches) == 0 {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot delete pending postures: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("cannot create pending posture dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(batches, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encode pending postures: %w", err)
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write pending postures: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("cannot atomically replace pending postures: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func enqueuePendingPostureBatch(
|
||||
dir string,
|
||||
results []PostureResultPayload,
|
||||
queuedAt time.Time,
|
||||
) (int, error) {
|
||||
if len(results) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
clonedResults := make([]PostureResultPayload, len(results))
|
||||
copy(clonedResults, results)
|
||||
|
||||
batches = append(
|
||||
batches,
|
||||
pendingPostureBatch{
|
||||
QueuedAt: queuedAt.UTC(),
|
||||
Results: clonedResults,
|
||||
},
|
||||
)
|
||||
|
||||
dropped := 0
|
||||
if len(batches) > maxPendingPostureBatches {
|
||||
dropped = len(batches) - maxPendingPostureBatches
|
||||
batches = batches[dropped:]
|
||||
}
|
||||
|
||||
if err := savePendingPostureBatches(dir, batches); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
func clearPendingPostureBatches(dir string) error {
|
||||
return savePendingPostureBatches(dir, nil)
|
||||
}
|
||||
237
pkg/deviceagent/posture_queue_test.go
Normal file
237
pkg/deviceagent/posture_queue_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package deviceagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPendingPostureQueue_EnqueueTrimsOldestBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
for i := range maxPendingPostureBatches + 3 {
|
||||
results := []PostureResultPayload{
|
||||
{
|
||||
CheckKey: fmt.Sprintf("check-%d", i),
|
||||
Status: "pass",
|
||||
ObservedAt: time.Unix(int64(i), 0).UTC(),
|
||||
},
|
||||
}
|
||||
dropped, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
results,
|
||||
time.Unix(int64(i), 0),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if i < maxPendingPostureBatches {
|
||||
assert.Equal(t, 0, dropped)
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, 1, dropped)
|
||||
}
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, maxPendingPostureBatches)
|
||||
assert.Equal(t, "check-3", batches[0].Results[0].CheckKey)
|
||||
assert.Equal(t, "check-98", batches[len(batches)-1].Results[0].CheckKey)
|
||||
}
|
||||
|
||||
func TestAgent_flushQueuedPostures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"clears queue when all batches are flushed",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.flushQueuedPostures(context.Background())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, batches, 0)
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"keeps unsent tail when a later flush request fails",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.flushQueuedPostures(context.Background())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, 1)
|
||||
assert.Equal(t, "second", batches[0].Results[0].CheckKey)
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"applies retry backoff with jitter gate after failures",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
calls.Add(1)
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Unix(10_000, 0).UTC()
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.now = func() time.Time { return now }
|
||||
a.randInt63n = func(n int64) int64 { return n / 2 }
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
assert.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff)
|
||||
firstRetryAt := a.pendingFlushRetryAt
|
||||
require.True(t, firstRetryAt.After(now))
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(1), calls.Load())
|
||||
|
||||
now = firstRetryAt.Add(time.Second)
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
assert.Equal(t, pendingFlushBackoffMin*2, a.pendingFlushBackoff)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"resets retry backoff after successful flush",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
_, err := enqueuePendingPostureBatch(
|
||||
dir,
|
||||
[]PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}},
|
||||
time.Now().UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/agent/v1/postures", r.URL.Path)
|
||||
call := calls.Add(1)
|
||||
if call == 1 {
|
||||
http.Error(w, "temporary error", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Unix(20_000, 0).UTC()
|
||||
a := New(dir, "test", nil)
|
||||
a.client = NewClient(srv.URL, "api-key", "test-agent")
|
||||
a.now = func() time.Time { return now }
|
||||
a.randInt63n = func(n int64) int64 { return n / 2 }
|
||||
|
||||
a.flushQueuedPostures(context.Background())
|
||||
require.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff)
|
||||
retryAt := a.pendingFlushRetryAt
|
||||
require.True(t, retryAt.After(now))
|
||||
|
||||
now = retryAt.Add(time.Second)
|
||||
a.flushQueuedPostures(context.Background())
|
||||
assert.Equal(t, int32(2), calls.Load())
|
||||
assert.Zero(t, a.pendingFlushBackoff)
|
||||
assert.True(t, a.pendingFlushRetryAt.IsZero())
|
||||
|
||||
batches, err := loadPendingPostureBatches(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, batches, 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
33
pkg/deviceagent/service/service.go
Normal file
33
pkg/deviceagent/service/service.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Package service installs and uninstalls OS service units for probo-agent.
|
||||
package service
|
||||
|
||||
// Config carries installation parameters shared across platforms.
|
||||
type Config struct {
|
||||
// ExePath is the agent binary path.
|
||||
ExePath string
|
||||
// Dir is the agent state directory.
|
||||
Dir string
|
||||
// Label is the service identifier.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Default service identifiers by platform.
|
||||
const (
|
||||
DefaultLabel = "com.getprobo.agent"
|
||||
DefaultUnixName = "probo-agent"
|
||||
DefaultWindowsName = "ProboAgent"
|
||||
)
|
||||
119
pkg/deviceagent/service/service_darwin.go
Normal file
119
pkg/deviceagent/service/service_darwin.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const plistPath = "/Library/LaunchDaemons/com.getprobo.agent.plist"
|
||||
|
||||
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{{xml .Label}}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{xml .ExePath}}</string>
|
||||
<string>run</string>
|
||||
<string>--dir</string>
|
||||
<string>{{xml .Dir}}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/probo-agent.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/probo-agent.log</string>
|
||||
<key>UserName</key>
|
||||
<string>root</string>
|
||||
<key>GroupName</key>
|
||||
<string>wheel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
func xmlEscape(v string) (string, error) {
|
||||
var sb strings.Builder
|
||||
if err := xml.EscapeText(&sb, []byte(v)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Install writes and boots the launchd plist.
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
if cfg.Label == "" {
|
||||
cfg.Label = DefaultLabel
|
||||
}
|
||||
|
||||
tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse plist template: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure launch daemons directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(plistPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write plist (need root?): %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := tmpl.Execute(f, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render plist: %w", err)
|
||||
}
|
||||
|
||||
// `bootout` first keeps install idempotent.
|
||||
_ = exec.Command("launchctl", "bootout", "system", plistPath).Run()
|
||||
if out, err := exec.Command("launchctl", "bootstrap", "system", plistPath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run launchctl bootstrap: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall bootouts and removes the launchd plist.
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("launchctl", "bootout", "system", plistPath).Run()
|
||||
if err := os.Remove(plistPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove plist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
97
pkg/deviceagent/service/service_freebsd.go
Normal file
97
pkg/deviceagent/service/service_freebsd.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
rcScriptPath = "/usr/local/etc/rc.d/probo_agent"
|
||||
)
|
||||
|
||||
// FreeBSD rc.d script template.
|
||||
const rcScriptTmpl = `#!/bin/sh
|
||||
#
|
||||
# PROVIDE: probo_agent
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name=probo_agent
|
||||
rcvar=probo_agent_enable
|
||||
desc="Probo device posture agent"
|
||||
pidfile="/var/run/${name}.pid"
|
||||
procname="{{.ExePath}}"
|
||||
command=/usr/sbin/daemon
|
||||
command_args="-r -P ${pidfile} -- \"{{.ExePath}}\" run --dir \"{{.Dir}}\""
|
||||
|
||||
load_rc_config $name
|
||||
: ${probo_agent_enable:=YES}
|
||||
|
||||
run_rc_command "$1"
|
||||
`
|
||||
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
rcTmpl, err := template.New("rc").Parse(rcScriptTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse rc.d template: %w", err)
|
||||
}
|
||||
|
||||
sf, err := os.OpenFile(rcScriptPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write rc.d script (need root?): %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = sf.Close() }()
|
||||
if err := rcTmpl.Execute(sf, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render rc.d script: %w", err)
|
||||
}
|
||||
|
||||
if out, err := exec.Command("service", "probo_agent", "enable").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run service probo_agent enable: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("service", "probo_agent", "start").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run service probo_agent start: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("service", "probo_agent", "stop").Run()
|
||||
_ = exec.Command("service", "probo_agent", "disable").Run()
|
||||
|
||||
if err := os.Remove(rcScriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove rc.d script: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
97
pkg/deviceagent/service/service_linux.go
Normal file
97
pkg/deviceagent/service/service_linux.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
systemdUnitPath = "/etc/systemd/system/probo-agent.service"
|
||||
)
|
||||
|
||||
const systemdUnitTmpl = `[Unit]
|
||||
Description=Probo device posture agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={{.ExePath}} run --dir {{.Dir}}
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# 75 is the exit code emitted after a successful self-update.
|
||||
# Treat it as a normal exit so the unit restarts without entering
|
||||
# the "failed" state.
|
||||
SuccessExitStatus=75
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
|
||||
tmpl, err := template.New("unit").Parse(systemdUnitTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse unit template: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(systemdUnitPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write systemd unit (need root?): %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := tmpl.Execute(f, cfg); err != nil {
|
||||
return fmt.Errorf("cannot render systemd unit: %w", err)
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run systemctl daemon-reload: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "enable", "--now", "probo-agent.service").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run systemctl enable --now: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
_ = exec.Command("systemctl", "disable", "--now", "probo-agent.service").Run()
|
||||
if err := os.Remove(systemdUnitPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cannot remove systemd unit: %w", err)
|
||||
}
|
||||
|
||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
73
pkg/deviceagent/service/service_windows.go
Normal file
73
pkg/deviceagent/service/service_windows.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Install registers and starts the Windows service via sc.exe.
|
||||
func Install(cfg Config) error {
|
||||
if cfg.ExePath == "" {
|
||||
return errors.New("executable path is required")
|
||||
}
|
||||
if cfg.Dir == "" {
|
||||
return errors.New("state directory is required")
|
||||
}
|
||||
name := DefaultWindowsName
|
||||
|
||||
bin := fmt.Sprintf(`"%s" run --dir "%s"`, cfg.ExePath, cfg.Dir)
|
||||
if out, err := exec.Command(
|
||||
"sc.exe",
|
||||
"create",
|
||||
name,
|
||||
"binPath=",
|
||||
bin,
|
||||
"start=",
|
||||
"auto",
|
||||
"DisplayName=",
|
||||
"Probo Device Posture Agent",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
// Restart on failure.
|
||||
if out, err := exec.Command(
|
||||
"sc.exe",
|
||||
"failure",
|
||||
name,
|
||||
"reset=",
|
||||
"86400",
|
||||
"actions=",
|
||||
"restart/1000/restart/1000/restart/1000",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe failure: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if out, err := exec.Command("sc.exe", "start", name).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe start: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Uninstall(cfg Config) error {
|
||||
name := DefaultWindowsName
|
||||
_ = exec.Command("sc.exe", "stop", name).Run()
|
||||
if out, err := exec.Command("sc.exe", "delete", name).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cannot run sc.exe delete: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
151
pkg/deviceagent/update/archive.go
Normal file
151
pkg/deviceagent/update/archive.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
maxExtractedFileSize = 200 * 1024 * 1024 // 200 MiB hard cap per file
|
||||
)
|
||||
|
||||
// extractBinary extracts the agent binary at
|
||||
// `<ArchiveDir>/<BinaryName>` from archivePath into workDir and
|
||||
// returns the absolute path of the written binary.
|
||||
func extractBinary(archivePath string, layout AssetLayout, workDir string) (string, error) {
|
||||
wantPath := path.Join(layout.ArchiveDir, layout.BinaryName)
|
||||
dest := filepath.Join(workDir, "probo-agent.new")
|
||||
|
||||
if layout.IsZip {
|
||||
if err := extractZipFile(archivePath, wantPath, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := extractTarGzFile(archivePath, wantPath, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
return "", fmt.Errorf("update: extracted binary missing: %w", err)
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func extractTarGzFile(archivePath, wantPath, dest string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open archive: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read tar entry: %w", err)
|
||||
}
|
||||
|
||||
if path.Clean(hdr.Name) != wantPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
return fmt.Errorf("update: %s is not a regular file", wantPath)
|
||||
}
|
||||
|
||||
return writeStream(dest, tr, 0o755)
|
||||
}
|
||||
|
||||
return fmt.Errorf("update: %s missing from archive", wantPath)
|
||||
}
|
||||
|
||||
func extractZipFile(archivePath, wantPath, dest string) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open zip: %w", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
for _, f := range r.File {
|
||||
if path.Clean(f.Name) != wantPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
return fmt.Errorf("update: %s is a directory", wantPath)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s in zip: %w", wantPath, err)
|
||||
}
|
||||
|
||||
err = writeStream(dest, rc, 0o755)
|
||||
_ = rc.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("update: %s missing from archive", wantPath)
|
||||
}
|
||||
|
||||
func writeStream(dest string, src io.Reader, mode os.FileMode) error {
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, io.LimitReader(src, maxExtractedFileSize+1)); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("cannot write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("cannot close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if stat.Size() > maxExtractedFileSize {
|
||||
_ = os.Remove(dest)
|
||||
return fmt.Errorf("update: extracted file exceeds %d bytes", maxExtractedFileSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
92
pkg/deviceagent/update/asset.go
Normal file
92
pkg/deviceagent/update/asset.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AssetLayout describes the names used by the release pipeline for a
|
||||
// (goos, goarch) combination. The fields mirror what the
|
||||
// release-probo-agent.yaml workflow produces.
|
||||
type AssetLayout struct {
|
||||
// ArchiveName is the file name of the published archive
|
||||
// (e.g. probo-agent_Linux_x86_64.tar.gz).
|
||||
ArchiveName string
|
||||
// ArchiveDir is the top-level directory inside the archive
|
||||
// (e.g. probo-agent_Linux_x86_64).
|
||||
ArchiveDir string
|
||||
// BinaryName is the agent binary file name inside the archive
|
||||
// (e.g. probo-agent or probo-agent.exe).
|
||||
BinaryName string
|
||||
// IsZip is true for Windows builds, which ship as zip archives.
|
||||
// Other platforms ship as gzipped tar.
|
||||
IsZip bool
|
||||
}
|
||||
|
||||
// LayoutFor returns the asset layout for a given (goos, goarch).
|
||||
//
|
||||
// The mapping is the inverse of the case statements in the release
|
||||
// workflow: linux/Linux, darwin/Darwin, windows/Windows, freebsd/Freebsd
|
||||
// and amd64 -> x86_64 (others kept as-is).
|
||||
func LayoutFor(goos, goarch string) (AssetLayout, error) {
|
||||
osLabel, err := osLabel(goos)
|
||||
if err != nil {
|
||||
return AssetLayout{}, err
|
||||
}
|
||||
|
||||
archLabel := archLabel(goarch)
|
||||
dir := fmt.Sprintf("probo-agent_%s_%s", osLabel, archLabel)
|
||||
|
||||
binary := "probo-agent"
|
||||
isZip := false
|
||||
ext := "tar.gz"
|
||||
if goos == "windows" {
|
||||
binary += ".exe"
|
||||
isZip = true
|
||||
ext = "zip"
|
||||
}
|
||||
|
||||
return AssetLayout{
|
||||
ArchiveName: fmt.Sprintf("%s.%s", dir, ext),
|
||||
ArchiveDir: dir,
|
||||
BinaryName: binary,
|
||||
IsZip: isZip,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func osLabel(goos string) (string, error) {
|
||||
switch strings.ToLower(goos) {
|
||||
case "linux":
|
||||
return "Linux", nil
|
||||
case "darwin":
|
||||
return "Darwin", nil
|
||||
case "windows":
|
||||
return "Windows", nil
|
||||
case "freebsd":
|
||||
return "Freebsd", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported GOOS %q for auto-update", goos)
|
||||
}
|
||||
|
||||
func archLabel(goarch string) string {
|
||||
if goarch == "amd64" {
|
||||
return "x86_64"
|
||||
}
|
||||
|
||||
return goarch
|
||||
}
|
||||
69
pkg/deviceagent/update/asset_test.go
Normal file
69
pkg/deviceagent/update/asset_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLayoutFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
goos, goarch string
|
||||
archive string
|
||||
dir string
|
||||
binary string
|
||||
isZip bool
|
||||
}{
|
||||
{"linux", "amd64", "probo-agent_Linux_x86_64.tar.gz", "probo-agent_Linux_x86_64", "probo-agent", false},
|
||||
{"linux", "arm64", "probo-agent_Linux_arm64.tar.gz", "probo-agent_Linux_arm64", "probo-agent", false},
|
||||
{"darwin", "amd64", "probo-agent_Darwin_x86_64.tar.gz", "probo-agent_Darwin_x86_64", "probo-agent", false},
|
||||
{"darwin", "arm64", "probo-agent_Darwin_arm64.tar.gz", "probo-agent_Darwin_arm64", "probo-agent", false},
|
||||
{"windows", "amd64", "probo-agent_Windows_x86_64.zip", "probo-agent_Windows_x86_64", "probo-agent.exe", true},
|
||||
{"windows", "arm64", "probo-agent_Windows_arm64.zip", "probo-agent_Windows_arm64", "probo-agent.exe", true},
|
||||
{"freebsd", "amd64", "probo-agent_Freebsd_x86_64.tar.gz", "probo-agent_Freebsd_x86_64", "probo-agent", false},
|
||||
{"freebsd", "arm64", "probo-agent_Freebsd_arm64.tar.gz", "probo-agent_Freebsd_arm64", "probo-agent", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(
|
||||
tc.goos+"/"+tc.goarch,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor(tc.goos, tc.goarch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.archive, layout.ArchiveName)
|
||||
assert.Equal(t, tc.dir, layout.ArchiveDir)
|
||||
assert.Equal(t, tc.binary, layout.BinaryName)
|
||||
assert.Equal(t, tc.isZip, layout.IsZip)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"unsupported GOOS",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := LayoutFor("plan9", "amd64")
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
101
pkg/deviceagent/update/install_unix.go
Normal file
101
pkg/deviceagent/update/install_unix.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// replaceBinary replaces the file at dst with src.
|
||||
//
|
||||
// On Unix the rename is atomic: the kernel keeps the running
|
||||
// executable mapped via its inode, while the destination path now
|
||||
// points at the new binary on disk. The next exec (after the
|
||||
// supervisor restarts the process) loads the new code.
|
||||
//
|
||||
// We try a same-directory rename first, then fall back to a
|
||||
// copy + atomic rename when src and dst live on different
|
||||
// filesystems (e.g. when /tmp is a tmpfs separate from /usr/local/bin).
|
||||
func replaceBinary(dst, src string) error {
|
||||
if err := os.Chmod(src, 0o755); err != nil {
|
||||
return fmt.Errorf("cannot chmod new binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cross-filesystem fallback: copy into <dst>.new, fsync,
|
||||
// then rename within the destination directory.
|
||||
staging := dst + ".new"
|
||||
if err := copyFile(src, staging); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(staging, 0o755); err != nil {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot chmod staged binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(staging, dst); err != nil {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot atomically replace %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot copy to %s: %w", dst, err)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot fsync %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot close %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupAfterRestart removes any leftover .old binary from a
|
||||
// previous Windows-style swap. On Unix this is a no-op.
|
||||
func CleanupAfterRestart(_ string) {}
|
||||
110
pkg/deviceagent/update/install_windows.go
Normal file
110
pkg/deviceagent/update/install_windows.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const oldSuffix = ".old"
|
||||
|
||||
// replaceBinary swaps dst with src on Windows.
|
||||
//
|
||||
// Windows blocks deletion / replacement of the running .exe but does
|
||||
// allow renaming a locked .exe out of the way. We:
|
||||
//
|
||||
// 1. Stage src as `<dst>.new` (same directory, so the final rename is
|
||||
// just a metadata update and won't cross volumes).
|
||||
// 2. Move the running binary to `<dst>.old` (NTFS lets us rename a
|
||||
// locked exe).
|
||||
// 3. Move `<dst>.new` into place at `<dst>`.
|
||||
//
|
||||
// On the next start the agent's main() calls CleanupAfterRestart to
|
||||
// best-effort delete `<dst>.old`.
|
||||
func replaceBinary(dst, src string) error {
|
||||
staging := dst + ".new"
|
||||
if err := copyFile(src, staging); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := dst + oldSuffix
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
if err := os.Rename(dst, oldPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot move running binary aside: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(staging, dst); err != nil {
|
||||
// Try to roll back the running binary swap.
|
||||
_ = os.Rename(oldPath, dst)
|
||||
_ = os.Remove(staging)
|
||||
return fmt.Errorf("cannot install new binary at %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot copy to %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot fsync %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return fmt.Errorf("cannot close %s: %w", dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupAfterRestart removes the previous-version binary left behind
|
||||
// by replaceBinary. Best-effort: callers ignore errors, so a still-locked
|
||||
// `<exePath>.old` is fine and will be retried on the next boot.
|
||||
func CleanupAfterRestart(exePath string) {
|
||||
if exePath == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(exePath + oldSuffix)
|
||||
}
|
||||
588
pkg/deviceagent/update/update.go
Normal file
588
pkg/deviceagent/update/update.go
Normal file
@@ -0,0 +1,588 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Package update self-updates the probo-agent binary from GitHub
|
||||
// Releases. The update flow is:
|
||||
//
|
||||
// 1. List the latest releases for the configured repo, filtering on a
|
||||
// tag prefix (`probo-agent/v` by default).
|
||||
// 2. Pick the highest semver newer than the agent's current version.
|
||||
// 3. Download the matching archive plus checksums.txt, verify SHA-256.
|
||||
// 4. Extract the archive and atomically replace the running binary.
|
||||
//
|
||||
// The caller is responsible for restarting the process so the OS
|
||||
// service supervisor re-execs the new binary.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRepo is the GitHub repository hosting probo-agent
|
||||
// releases.
|
||||
DefaultRepo = "getprobo/probo"
|
||||
// DefaultTagPrefix is the tag prefix used by the agent's release
|
||||
// pipeline. Releases look like `probo-agent/v0.1.0`.
|
||||
DefaultTagPrefix = "probo-agent/v"
|
||||
|
||||
defaultAPIBaseURL = "https://api.github.com"
|
||||
defaultAssetBaseURL = "https://github.com"
|
||||
defaultPageSize = 30
|
||||
defaultDownloadLimit = 200 * 1024 * 1024 // 200 MiB cap on archive size
|
||||
checksumFileName = "checksums.txt"
|
||||
checksumBundleFileName = "checksums.txt.bundle"
|
||||
)
|
||||
|
||||
// ErrNoUpdateAvailable is returned by CheckLatest when no release
|
||||
// newer than the current version exists.
|
||||
var ErrNoUpdateAvailable = errors.New("no update available")
|
||||
|
||||
type (
|
||||
// Updater self-updates the agent binary on the local host.
|
||||
Updater struct {
|
||||
Repo string
|
||||
TagPrefix string
|
||||
APIBaseURL string
|
||||
AssetBaseURL string
|
||||
CurrentVersion string
|
||||
ExePath string
|
||||
UserAgent string
|
||||
HTTP *http.Client
|
||||
Logger *log.Logger
|
||||
|
||||
// SigstoreCacheDir is the on-disk directory used by the
|
||||
// default cosign Verifier to cache Sigstore TUF metadata.
|
||||
// Required when Verifier is nil.
|
||||
SigstoreCacheDir string
|
||||
|
||||
// Verifier validates the Sigstore bundle that accompanies
|
||||
// every release. When nil, the default cosign Verifier is
|
||||
// constructed lazily on first Apply, pinned to the
|
||||
// probo-agent release workflow.
|
||||
Verifier Verifier
|
||||
|
||||
// GOOS/GOARCH override the values used to compute the
|
||||
// archive name. They default to runtime.GOOS/GOARCH and
|
||||
// exist for tests.
|
||||
GOOS string
|
||||
GOARCH string
|
||||
}
|
||||
|
||||
// Release describes a candidate update.
|
||||
Release struct {
|
||||
Version string
|
||||
Tag string
|
||||
AssetName string
|
||||
AssetURL string
|
||||
ChecksumURL string
|
||||
ChecksumBundleURL string
|
||||
}
|
||||
|
||||
githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
PublishedAt jsonTimestamp `json:"published_at"`
|
||||
}
|
||||
|
||||
githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
jsonTimestamp time.Time
|
||||
)
|
||||
|
||||
func (j *jsonTimestamp) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*j = jsonTimestamp(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New returns an Updater with sane defaults for production use.
|
||||
//
|
||||
// sigstoreCacheDir is the on-disk directory used to cache Sigstore
|
||||
// TUF metadata for cosign bundle verification. It MUST be writable by
|
||||
// the agent. A typical value is `<agent state dir>/sigstore-cache`.
|
||||
func New(currentVersion, exePath, userAgent, sigstoreCacheDir string, logger *log.Logger) *Updater {
|
||||
if logger == nil {
|
||||
logger = log.NewLogger(log.WithName("agent-update"))
|
||||
}
|
||||
|
||||
return &Updater{
|
||||
Repo: DefaultRepo,
|
||||
TagPrefix: DefaultTagPrefix,
|
||||
APIBaseURL: defaultAPIBaseURL,
|
||||
AssetBaseURL: defaultAssetBaseURL,
|
||||
CurrentVersion: currentVersion,
|
||||
ExePath: exePath,
|
||||
UserAgent: userAgent,
|
||||
Logger: logger,
|
||||
HTTP: defaultHTTPClient(logger),
|
||||
SigstoreCacheDir: sigstoreCacheDir,
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHTTPClient(logger *log.Logger) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: httpclient.DefaultPooledTransport(
|
||||
httpclient.WithLogger(logger),
|
||||
httpclient.WithSSRFProtection(),
|
||||
),
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckLatest queries GitHub for the highest semver release whose tag
|
||||
// matches u.TagPrefix and is newer than u.CurrentVersion. It returns
|
||||
// ErrNoUpdateAvailable when nothing newer exists.
|
||||
func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) {
|
||||
layout, err := LayoutFor(u.goos(), u.goarch())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releases, err := u.listReleases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := normalizeSemver(u.CurrentVersion)
|
||||
|
||||
var best *Release
|
||||
for i := range releases {
|
||||
rel := &releases[i]
|
||||
if rel.Draft || rel.Prerelease {
|
||||
continue
|
||||
}
|
||||
|
||||
ver, ok := parseTag(rel.TagName, u.TagPrefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip anything that is not strictly newer than the running
|
||||
// version. When running a dev build (`current` is empty)
|
||||
// every published release is considered newer.
|
||||
if current != "" && semver.Compare(normalizeSemver(ver), current) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if best != nil && semver.Compare(normalizeSemver(ver), normalizeSemver(best.Version)) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
assetURL, ok := findAssetURL(rel.Assets, layout.ArchiveName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checksumURL, ok := findAssetURL(rel.Assets, checksumFileName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bundleURL, ok := findAssetURL(rel.Assets, checksumBundleFileName)
|
||||
if !ok {
|
||||
// Releases without a Sigstore bundle predate the
|
||||
// signed-release pipeline and cannot be verified.
|
||||
// Skip them so the agent never auto-installs an
|
||||
// unsigned artifact.
|
||||
continue
|
||||
}
|
||||
|
||||
best = &Release{
|
||||
Version: ver,
|
||||
Tag: rel.TagName,
|
||||
AssetName: layout.ArchiveName,
|
||||
AssetURL: assetURL,
|
||||
ChecksumURL: checksumURL,
|
||||
ChecksumBundleURL: bundleURL,
|
||||
}
|
||||
}
|
||||
|
||||
if best == nil {
|
||||
return nil, ErrNoUpdateAvailable
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// Apply downloads the release archive, verifies the Sigstore bundle
|
||||
// covering checksums.txt, verifies the SHA-256 of the archive against
|
||||
// the now-trusted checksums.txt, extracts the binary to a temp
|
||||
// directory, and atomically replaces u.ExePath with the new binary.
|
||||
//
|
||||
// Failure at *any* verification step aborts the update without
|
||||
// touching the running binary.
|
||||
func (u *Updater) Apply(ctx context.Context, rel *Release) error {
|
||||
if rel == nil {
|
||||
return errors.New("nil release")
|
||||
}
|
||||
|
||||
if u.ExePath == "" {
|
||||
return errors.New("agent executable path is empty")
|
||||
}
|
||||
|
||||
if rel.ChecksumBundleURL == "" {
|
||||
return errors.New("release is not signed (no checksums.txt.bundle)")
|
||||
}
|
||||
|
||||
layout, err := LayoutFor(u.goos(), u.goarch())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verifier, err := u.resolveVerifier()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "probo-agent-update-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create update workdir: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(workDir) }()
|
||||
|
||||
archivePath := filepath.Join(workDir, layout.ArchiveName)
|
||||
if err := u.downloadFile(ctx, rel.AssetURL, archivePath); err != nil {
|
||||
return fmt.Errorf("cannot download archive: %w", err)
|
||||
}
|
||||
|
||||
checksumPath := filepath.Join(workDir, checksumFileName)
|
||||
if err := u.downloadFile(ctx, rel.ChecksumURL, checksumPath); err != nil {
|
||||
return fmt.Errorf("cannot download checksums: %w", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(workDir, checksumBundleFileName)
|
||||
if err := u.downloadFile(ctx, rel.ChecksumBundleURL, bundlePath); err != nil {
|
||||
return fmt.Errorf("cannot download sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
// Anchor the trust chain: verify that the bundle attests
|
||||
// checksums.txt was signed by the pinned release workflow,
|
||||
// before reading anything from checksums.txt.
|
||||
if err := verifier.Verify(ctx, checksumPath, bundlePath); err != nil {
|
||||
return fmt.Errorf("cannot verify sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
if err := verifyChecksum(archivePath, checksumPath, layout.ArchiveName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedBinary, err := extractBinary(archivePath, layout, workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot extract archive: %w", err)
|
||||
}
|
||||
|
||||
if err := replaceBinary(u.ExePath, extractedBinary); err != nil {
|
||||
return fmt.Errorf("cannot replace agent binary: %w", err)
|
||||
}
|
||||
|
||||
u.Logger.InfoCtx(
|
||||
ctx,
|
||||
"agent binary updated",
|
||||
log.String("version", rel.Version),
|
||||
log.String("tag", rel.Tag),
|
||||
log.String("asset", rel.AssetName),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveVerifier returns a non-nil Verifier, lazily building the
|
||||
// default cosign-backed verifier when none was injected.
|
||||
func (u *Updater) resolveVerifier() (Verifier, error) {
|
||||
if u.Verifier != nil {
|
||||
return u.Verifier, nil
|
||||
}
|
||||
|
||||
if u.SigstoreCacheDir == "" {
|
||||
return nil, errors.New("SigstoreCacheDir must be set for default cosign verifier")
|
||||
}
|
||||
|
||||
v, err := NewCosignVerifier(
|
||||
CosignVerifierConfig{
|
||||
Repo: u.Repo,
|
||||
WorkflowPath: expectedWorkflowPath,
|
||||
TagPrefix: u.TagPrefix,
|
||||
CacheDir: u.SigstoreCacheDir,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Verifier = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// listReleases returns the most recent page of releases from GitHub.
|
||||
func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) {
|
||||
apiBase := u.APIBaseURL
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBaseURL
|
||||
}
|
||||
|
||||
endpoint, err := url.JoinPath(apiBase, "repos", u.Repo, "releases")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build releases URL: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse releases URL: %w", err)
|
||||
}
|
||||
|
||||
q := parsed.Query()
|
||||
q.Set("per_page", fmt.Sprintf("%d", defaultPageSize))
|
||||
parsed.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build releases request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", u.userAgent())
|
||||
|
||||
resp, err := u.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch releases: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("cannot fetch releases: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var out []githubRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4*1024*1024)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode releases: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/octet-stream")
|
||||
req.Header.Set("User-Agent", u.userAgent())
|
||||
|
||||
resp, err := u.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("cannot fetch %s: %d %s", src, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
tmp := dst + ".part"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s: %w", tmp, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, defaultDownloadLimit+1)); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("cannot stream %s: %w", src, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("cannot close %s: %w", tmp, err)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat %s: %w", tmp, err)
|
||||
}
|
||||
if stat.Size() > defaultDownloadLimit {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download %s exceeds %d bytes", src, defaultDownloadLimit)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return fmt.Errorf("cannot move %s into place: %w", tmp, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) userAgent() string {
|
||||
if u.UserAgent != "" {
|
||||
return u.UserAgent
|
||||
}
|
||||
|
||||
return "probo-agent-updater"
|
||||
}
|
||||
|
||||
func (u *Updater) goos() string {
|
||||
if u.GOOS != "" {
|
||||
return u.GOOS
|
||||
}
|
||||
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (u *Updater) goarch() string {
|
||||
if u.GOARCH != "" {
|
||||
return u.GOARCH
|
||||
}
|
||||
|
||||
return runtime.GOARCH
|
||||
}
|
||||
|
||||
// parseTag returns the version (e.g. "0.2.0") for a tag whose value
|
||||
// starts with prefix (e.g. "probo-agent/v").
|
||||
func parseTag(tag, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(tag, prefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
v := strings.TrimPrefix(tag, prefix)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !semver.IsValid("v" + v) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
|
||||
// normalizeSemver returns the canonical form expected by golang.org/x/mod/semver
|
||||
// (a "v" prefix), or "" when the input is empty / invalid.
|
||||
func normalizeSemver(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
|
||||
if !semver.IsValid(v) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func findAssetURL(assets []githubAsset, name string) (string, bool) {
|
||||
for _, a := range assets {
|
||||
if a.Name == name {
|
||||
return a.BrowserDownloadURL, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// verifyChecksum checks that the SHA-256 digest of archivePath
|
||||
// matches the entry for archiveName in the checksums.txt file
|
||||
// produced by `sha256sum *.tar.gz *.zip`.
|
||||
func verifyChecksum(archivePath, checksumPath, archiveName string) error {
|
||||
expected, err := readChecksum(checksumPath, archiveName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open archive: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("cannot hash archive: %w", err)
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
if !strings.EqualFold(actual, expected) {
|
||||
return fmt.Errorf(
|
||||
"update: checksum mismatch for %s (expected %s, got %s)",
|
||||
archiveName,
|
||||
expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChecksum(path, archiveName string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read checksums: %w", err)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// `sha256sum` output is `<hex> <name>`; the GNU tool also
|
||||
// supports a single-space separator and a leading `*` flag
|
||||
// for binary mode. Handle both.
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(fields[1], "*")
|
||||
if name != archiveName {
|
||||
continue
|
||||
}
|
||||
|
||||
return strings.ToLower(fields[0]), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("update: %s missing from checksums file", archiveName)
|
||||
}
|
||||
460
pkg/deviceagent/update/update_test.go
Normal file
460
pkg/deviceagent/update/update_test.go
Normal file
@@ -0,0 +1,460 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func TestParseTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
tag string
|
||||
prefix string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"probo-agent/v0.1.0", "probo-agent/v", "0.1.0", true},
|
||||
{"probo-agent/v1.2.3", "probo-agent/v", "1.2.3", true},
|
||||
{"v1.2.3", "probo-agent/v", "", false},
|
||||
{"probo-agent/vlatest", "probo-agent/v", "", false},
|
||||
{"probo-agent/v", "probo-agent/v", "", false},
|
||||
{"unrelated/v0.1.0", "probo-agent/v", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, ok := parseTag(tc.tag, tc.prefix)
|
||||
assert.Equal(t, tc.ok, ok, tc.tag)
|
||||
assert.Equal(t, tc.want, got, tc.tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSemver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "v0.1.0", normalizeSemver("0.1.0"))
|
||||
assert.Equal(t, "v1.2.3", normalizeSemver("v1.2.3"))
|
||||
assert.Equal(t, "v1.2.3-alpha.1", normalizeSemver("1.2.3-alpha.1"))
|
||||
assert.Equal(t, "", normalizeSemver(""))
|
||||
assert.Equal(t, "", normalizeSemver("not-a-version"))
|
||||
}
|
||||
|
||||
func TestReadChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"plain sha256sum output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
content := "" +
|
||||
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef probo-agent_Linux_x86_64.tar.gz\n" +
|
||||
"abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd probo-agent_Darwin_arm64.tar.gz\n"
|
||||
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
|
||||
|
||||
got, err := readChecksum(file, "probo-agent_Darwin_arm64.tar.gz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"binary-mode flag is stripped",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
content := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef *probo-agent_Linux_x86_64.tar.gz\n"
|
||||
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
|
||||
|
||||
got, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"missing entry returns error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "checksums.txt")
|
||||
require.NoError(t, os.WriteFile(file, []byte("deadbeef other.tar.gz\n"), 0o600))
|
||||
|
||||
_, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// fakeReleaseServer simulates the GitHub releases API and the
|
||||
// browser_download_url asset endpoints.
|
||||
type fakeReleaseServer struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
|
||||
// release plumbing
|
||||
tag string
|
||||
prerelease bool
|
||||
draft bool
|
||||
|
||||
// archive plumbing
|
||||
binaryContent []byte
|
||||
archiveBytes []byte
|
||||
checksumLine string
|
||||
bundleBytes []byte
|
||||
|
||||
// when true, the release does not advertise a checksums.txt.bundle asset
|
||||
omitBundle bool
|
||||
}
|
||||
|
||||
func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, binary []byte) *fakeReleaseServer {
|
||||
t.Helper()
|
||||
|
||||
archive := buildArchive(t, layout, binary)
|
||||
sum := sha256.Sum256(archive)
|
||||
checksum := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), layout.ArchiveName)
|
||||
|
||||
frs := &fakeReleaseServer{
|
||||
t: t,
|
||||
tag: tag,
|
||||
binaryContent: binary,
|
||||
archiveBytes: archive,
|
||||
checksumLine: checksum,
|
||||
bundleBytes: []byte("dummy-sigstore-bundle"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host
|
||||
assets := []map[string]any{
|
||||
{
|
||||
"name": layout.ArchiveName,
|
||||
"browser_download_url": base + "/download/" + layout.ArchiveName,
|
||||
},
|
||||
{
|
||||
"name": checksumFileName,
|
||||
"browser_download_url": base + "/download/" + checksumFileName,
|
||||
},
|
||||
}
|
||||
if !frs.omitBundle {
|
||||
assets = append(assets, map[string]any{
|
||||
"name": checksumBundleFileName,
|
||||
"browser_download_url": base + "/download/" + checksumBundleFileName,
|
||||
})
|
||||
}
|
||||
body := []map[string]any{
|
||||
{
|
||||
"tag_name": frs.tag,
|
||||
"draft": frs.draft,
|
||||
"prerelease": frs.prerelease,
|
||||
"assets": assets,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
_ = version
|
||||
})
|
||||
mux.HandleFunc("/download/"+layout.ArchiveName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(frs.archiveBytes)
|
||||
})
|
||||
mux.HandleFunc("/download/"+checksumFileName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(frs.checksumLine))
|
||||
})
|
||||
mux.HandleFunc("/download/"+checksumBundleFileName, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(frs.bundleBytes)
|
||||
})
|
||||
|
||||
frs.server = httptest.NewServer(mux)
|
||||
t.Cleanup(frs.server.Close)
|
||||
return frs
|
||||
}
|
||||
|
||||
func (f *fakeReleaseServer) URL() string { return f.server.URL }
|
||||
|
||||
func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
if layout.IsZip {
|
||||
return buildZip(t, layout, binary)
|
||||
}
|
||||
return buildTarGz(t, layout, binary)
|
||||
}
|
||||
|
||||
func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, layout.ArchiveName)
|
||||
|
||||
f, err := os.Create(out)
|
||||
require.NoError(t, err)
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||
Name: path.Join(layout.ArchiveDir, layout.BinaryName),
|
||||
Mode: 0o755,
|
||||
Size: int64(len(binary)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}))
|
||||
_, err = tw.Write(binary)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gz.Close())
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
data, err := os.ReadFile(out)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, layout.ArchiveName)
|
||||
|
||||
f, err := os.Create(out)
|
||||
require.NoError(t, err)
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create(path.Join(layout.ArchiveDir, layout.BinaryName))
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write(binary)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
data, err := os.ReadFile(out)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func newTestUpdater(server *fakeReleaseServer, currentVersion, exePath, goos, goarch string) *Updater {
|
||||
return &Updater{
|
||||
Repo: "getprobo/probo",
|
||||
TagPrefix: DefaultTagPrefix,
|
||||
APIBaseURL: server.URL(),
|
||||
AssetBaseURL: server.URL(),
|
||||
CurrentVersion: currentVersion,
|
||||
ExePath: exePath,
|
||||
UserAgent: "probo-agent-test/0.0.0",
|
||||
Logger: log.NewLogger(log.WithName("update-test")),
|
||||
HTTP: &http.Client{
|
||||
Transport: httpclient.DefaultPooledTransport(
|
||||
httpclient.WithSSRFProtection(),
|
||||
httpclient.WithSSRFAllowLoopback(),
|
||||
),
|
||||
},
|
||||
// Tests bypass the cosign verifier; production code wires
|
||||
// CosignVerifier in via Updater.SigstoreCacheDir.
|
||||
Verifier: AllowAllVerifier{},
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdater_CheckLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns release when newer version is available",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.2.0", rel.Version)
|
||||
assert.Equal(t, layout.ArchiveName, rel.AssetName)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns ErrNoUpdateAvailable when running latest",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("darwin", "arm64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("same"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "darwin", "arm64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"skips draft and prerelease tags",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0-rc.1", "0.2.0-rc.1", layout, []byte("rc"))
|
||||
fake.prerelease = true
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"dev build always sees update available",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("rel"))
|
||||
|
||||
u := newTestUpdater(fake, "dev", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.1.0", rel.Version)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestUpdater_Apply(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("apply test exercises the unix swap path; windows has its own .old shuffle")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, u.Apply(context.Background(), rel))
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("new-binary"), got)
|
||||
|
||||
stat, err := os.Stat(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, stat.Mode().Perm()&0o100, "new binary should be executable")
|
||||
}
|
||||
|
||||
func TestUpdater_CheckLatest_SkipsUnsignedRelease(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
|
||||
fake.omitBundle = true
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
|
||||
_, err = u.CheckLatest(context.Background())
|
||||
assert.ErrorIs(t, err, ErrNoUpdateAvailable, "release without a sigstore bundle must be ignored")
|
||||
}
|
||||
|
||||
func TestUpdater_Apply_RejectsBadSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
u.Verifier = rejectAllVerifier{err: fmt.Errorf("test: signer identity mismatch")}
|
||||
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = u.Apply(context.Background(), rel)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sigstore")
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("old-binary"), got, "rejected signature must not touch the running binary")
|
||||
}
|
||||
|
||||
func TestUpdater_Apply_RejectsCorruptedArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "probo-agent")
|
||||
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
|
||||
|
||||
layout, err := LayoutFor("linux", "amd64")
|
||||
require.NoError(t, err)
|
||||
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
|
||||
|
||||
// Corrupt the archive without updating checksums.
|
||||
fake.archiveBytes = append(fake.archiveBytes, 0xff)
|
||||
|
||||
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
|
||||
rel, err := u.CheckLatest(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = u.Apply(context.Background(), rel)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "checksum mismatch")
|
||||
|
||||
got, err := os.ReadFile(exePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("old-binary"), got, "corrupted update must not touch the running binary")
|
||||
}
|
||||
194
pkg/deviceagent/update/verify.go
Normal file
194
pkg/deviceagent/update/verify.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/sigstore/sigstore-go/pkg/bundle"
|
||||
"github.com/sigstore/sigstore-go/pkg/root"
|
||||
"github.com/sigstore/sigstore-go/pkg/tuf"
|
||||
"github.com/sigstore/sigstore-go/pkg/verify"
|
||||
)
|
||||
|
||||
const (
|
||||
// expectedSignerIssuer is the OIDC issuer Fulcio embeds in the
|
||||
// signing certificate when the workflow uses GitHub Actions'
|
||||
// OIDC token. This is the public-good Sigstore configuration.
|
||||
expectedSignerIssuer = "https://token.actions.githubusercontent.com"
|
||||
|
||||
// expectedWorkflowPath is the path of the release workflow that
|
||||
// is allowed to produce signed probo-agent artifacts. Anything
|
||||
// signed by a different workflow (or a workflow run outside of
|
||||
// a tagged commit) is rejected.
|
||||
expectedWorkflowPath = ".github/workflows/release-probo-agent.yaml"
|
||||
)
|
||||
|
||||
// Verifier verifies that a Sigstore bundle (`checksums.txt.bundle`)
|
||||
// attests an artifact (`checksums.txt`) was produced by the expected
|
||||
// signer identity. Implementations MUST hard-fail on any error;
|
||||
// callers do not interpret the error type.
|
||||
type Verifier interface {
|
||||
// Verify returns nil iff bundlePath is a valid Sigstore bundle
|
||||
// for the artifact at artifactPath, and the signer identity
|
||||
// matches the verifier's pinned issuer / SAN regex.
|
||||
Verify(ctx context.Context, artifactPath, bundlePath string) error
|
||||
}
|
||||
|
||||
// AllowAllVerifier accepts every input. It exists strictly for tests
|
||||
// of the surrounding download / extract pipeline. Production callers
|
||||
// must wire a real Verifier (e.g. CosignVerifier).
|
||||
type AllowAllVerifier struct{}
|
||||
|
||||
// Verify always returns nil.
|
||||
func (AllowAllVerifier) Verify(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// rejectAllVerifier is exposed for tests that need to assert Apply
|
||||
// hard-fails on signature problems.
|
||||
type rejectAllVerifier struct{ err error }
|
||||
|
||||
func (v rejectAllVerifier) Verify(_ context.Context, _, _ string) error { return v.err }
|
||||
|
||||
// CosignVerifier verifies cosign sign-blob bundles using sigstore-go
|
||||
// against the Sigstore public-good trust root.
|
||||
//
|
||||
// The verifier pins the signer identity to the probo-agent release
|
||||
// workflow on a tagged commit:
|
||||
//
|
||||
// issuer: https://token.actions.githubusercontent.com
|
||||
// SAN: https://github.com/<repo>/<workflow>@refs/tags/<tag-prefix><version>
|
||||
//
|
||||
// where <repo>, <workflow> and <tag-prefix> default to the values
|
||||
// hard-coded in the release pipeline. Both fields can be overridden
|
||||
// for testing or for repository forks.
|
||||
type CosignVerifier struct {
|
||||
Issuer string
|
||||
SANRegex string
|
||||
trustedRoot *root.TrustedRoot
|
||||
}
|
||||
|
||||
// CosignVerifierConfig configures a CosignVerifier.
|
||||
type CosignVerifierConfig struct {
|
||||
// Repo identifies the GitHub repository (e.g. "getprobo/probo").
|
||||
Repo string
|
||||
// WorkflowPath is the path within the repo to the workflow file
|
||||
// allowed to produce signed releases.
|
||||
WorkflowPath string
|
||||
// TagPrefix is the tag prefix the release workflow signs against
|
||||
// (e.g. "probo-agent/v"). The verifier matches anything after
|
||||
// this prefix.
|
||||
TagPrefix string
|
||||
// CacheDir is the on-disk directory used to cache the Sigstore
|
||||
// TUF metadata. Required.
|
||||
CacheDir string
|
||||
}
|
||||
|
||||
// NewCosignVerifier loads the Sigstore public-good trust root via
|
||||
// TUF (cached under cfg.CacheDir) and returns a Verifier that pins
|
||||
// signatures to the configured GitHub Actions workflow on a tagged
|
||||
// commit.
|
||||
func NewCosignVerifier(cfg CosignVerifierConfig) (*CosignVerifier, error) {
|
||||
if cfg.Repo == "" {
|
||||
return nil, fmt.Errorf("update: cosign verifier requires Repo")
|
||||
}
|
||||
if cfg.WorkflowPath == "" {
|
||||
cfg.WorkflowPath = expectedWorkflowPath
|
||||
}
|
||||
if cfg.TagPrefix == "" {
|
||||
cfg.TagPrefix = DefaultTagPrefix
|
||||
}
|
||||
if cfg.CacheDir == "" {
|
||||
return nil, fmt.Errorf("update: cosign verifier requires CacheDir")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.CacheDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("cannot create sigstore cache dir: %w", err)
|
||||
}
|
||||
|
||||
opts := tuf.DefaultOptions()
|
||||
opts.CachePath = cfg.CacheDir
|
||||
|
||||
tufClient, err := tuf.New(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot init sigstore TUF client: %w", err)
|
||||
}
|
||||
|
||||
trustedRoot, err := root.GetTrustedRoot(tufClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load sigstore trusted root: %w", err)
|
||||
}
|
||||
|
||||
sanRegex := buildSANRegex(cfg.Repo, cfg.WorkflowPath, cfg.TagPrefix)
|
||||
|
||||
return &CosignVerifier{
|
||||
Issuer: expectedSignerIssuer,
|
||||
SANRegex: sanRegex,
|
||||
trustedRoot: trustedRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify validates that bundlePath attests artifactPath was signed by
|
||||
// the expected GitHub Actions workflow on a tagged release.
|
||||
func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath string) error {
|
||||
b, err := bundle.LoadJSONFromPath(bundlePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load sigstore bundle: %w", err)
|
||||
}
|
||||
|
||||
identity, err := verify.NewShortCertificateIdentity(v.Issuer, "", "", v.SANRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build signer identity: %w", err)
|
||||
}
|
||||
|
||||
sev, err := verify.NewVerifier(
|
||||
v.trustedRoot,
|
||||
verify.WithSignedCertificateTimestamps(1),
|
||||
verify.WithTransparencyLog(1),
|
||||
verify.WithObserverTimestamps(1),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build sigstore verifier: %w", err)
|
||||
}
|
||||
|
||||
artifact, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open artifact for verification: %w", err)
|
||||
}
|
||||
defer func() { _ = artifact.Close() }()
|
||||
|
||||
policy := verify.NewPolicy(
|
||||
verify.WithArtifact(artifact),
|
||||
verify.WithCertificateIdentity(identity),
|
||||
)
|
||||
|
||||
if _, err := sev.Verify(b, policy); err != nil {
|
||||
return fmt.Errorf("sigstore verification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSANRegex(repo, workflowPath, tagPrefix string) string {
|
||||
return `^https://github\.com/` +
|
||||
regexp.QuoteMeta(repo) +
|
||||
`/` +
|
||||
regexp.QuoteMeta(workflowPath) +
|
||||
`@refs/tags/` +
|
||||
regexp.QuoteMeta(tagPrefix) +
|
||||
`.+$`
|
||||
}
|
||||
Reference in New Issue
Block a user