Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-26 10:12:37 -07:00
parent e6f988834d
commit e040851a4c
20 changed files with 219 additions and 1 deletions

View File

@@ -56,6 +56,7 @@ func main() {
if errors.Is(err, deviceagent.ErrRestartRequired) {
os.Exit(restartExitCode)
}
fmt.Fprintf(os.Stderr, "probo-agent: %s\n", err)
os.Exit(1)
}
@@ -79,6 +80,7 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newStatusCmd())
root.AddCommand(newCollectCmd())
root.AddCommand(newUpdateCmd())
return root
}
@@ -145,10 +147,12 @@ func newInstallCmd() *cobra.Command {
}
dir := resolveDir(cmd)
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
defer cancel()
agent := deviceagent.New(dir, version, newAgentLogger())
resp, err := agent.EnrollNewDevice(ctx, strings.TrimRight(serverURL, "/"), enrollmentToken)
if err != nil {
return fmt.Errorf("enrollment failed: %w", err)
@@ -161,6 +165,7 @@ func newInstallCmd() *cobra.Command {
if err := persistAutoUpdate(dir, false); err != nil {
return fmt.Errorf("cannot persist auto-update preference: %w", err)
}
fmt.Println("Auto-update disabled.")
}
@@ -184,6 +189,7 @@ func newInstallCmd() *cobra.Command {
}
fmt.Println("Service installed and started.")
return nil
},
}
@@ -205,6 +211,7 @@ func persistAutoUpdate(dir string, enabled bool) error {
}
cfg.UpdatesDisabled = !enabled
return deviceagent.SaveConfig(dir, cfg)
}
@@ -228,6 +235,7 @@ func newUninstallCmd() *cobra.Command {
}
_ = os.Remove(deviceagent.ConfigPath(dir))
fmt.Println("Uninstalled.")
return nil
@@ -248,6 +256,7 @@ func newRunCmd() *cobra.Command {
logger := newAgentLogger()
agent := deviceagent.New(dir, version, logger)
agent.Updater = newUpdater(logger, dir)
err := agent.Run(ctx)
if errors.Is(err, context.Canceled) {
return nil
@@ -264,6 +273,7 @@ func newStatusCmd() *cobra.Command {
Short: "Print the agent's local state",
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveDir(cmd)
cfg, err := deviceagent.LoadConfig(dir)
if err != nil {
return err
@@ -294,6 +304,7 @@ func newCollectCmd() *cobra.Command {
asJSON bool
printDir bool
)
cmd := &cobra.Command{
Use: "collect",
Short: "Run the posture check set once and print results (no server push)",
@@ -329,12 +340,14 @@ func newCollectCmd() *cobra.Command {
func newUpdateCmd() *cobra.Command {
var checkOnly bool
cmd := &cobra.Command{
Use: "update",
Short: "Check GitHub for a newer agent release and install it in place",
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveDir(cmd)
logger := newAgentLogger()
updater := newUpdater(logger, dir)
if updater == nil {
return errors.New("cannot resolve current executable path")
@@ -349,10 +362,12 @@ func newUpdateCmd() *cobra.Command {
fmt.Printf("probo-agent is up to date (version %s).\n", version)
return nil
}
return fmt.Errorf("cannot check for updates: %w", err)
}
fmt.Printf("Update available: %s -> %s\n", version, rel.Version)
if checkOnly {
return nil
}
@@ -362,6 +377,7 @@ func newUpdateCmd() *cobra.Command {
}
fmt.Printf("Installed probo-agent %s. Restart the service to use it.\n", rel.Version)
return nil
},
}

View File

@@ -72,6 +72,7 @@ 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,
@@ -95,6 +96,7 @@ func (a *Agent) EnrollNewDevice(
if serverURL == "" {
return nil, errors.New("server URL is required")
}
if enrollmentToken == "" {
return nil, errors.New("enrollment token is required")
}
@@ -127,15 +129,18 @@ func (a *Agent) EnrollNewDevice(
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
}
@@ -145,15 +150,19 @@ func (a *Agent) LoadLocalState() error {
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
}
@@ -185,6 +194,7 @@ func (a *Agent) Run(ctx context.Context) error {
heartbeatTicker := time.NewTicker(a.cfg.HeartbeatInterval)
defer heartbeatTicker.Stop()
postureTicker := time.NewTicker(a.cfg.PostureInterval)
defer postureTicker.Stop()
@@ -203,6 +213,7 @@ func (a *Agent) Run(ctx context.Context) error {
if heartbeatIntervalChanged {
heartbeatTicker.Reset(a.cfg.HeartbeatInterval)
}
if postureIntervalChanged {
postureTicker.Reset(a.cfg.PostureInterval)
}
@@ -223,9 +234,11 @@ func (a *Agent) autoUpdateEnabled() bool {
if a.cfg == nil {
return false
}
if a.cfg.UpdatesDisabled {
return false
}
return a.Updater != nil
}
@@ -238,6 +251,7 @@ func (a *Agent) newUpdateTicker() (*time.Ticker, <-chan time.Time) {
}
t := time.NewTicker(a.cfg.UpdateInterval)
return t, t.C
}
@@ -258,7 +272,9 @@ func (a *Agent) tryAutoUpdate(parent context.Context) bool {
a.Logger.DebugCtx(ctx, "no agent update available")
return false
}
a.Logger.WarnCtx(ctx, "agent update check failed", log.Error(err))
return false
}
@@ -281,23 +297,30 @@ func (a *Agent) tryAutoUpdate(parent context.Context) bool {
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
}
@@ -308,6 +331,7 @@ func (a *Agent) Unenroll(ctx context.Context) error {
return err
}
}
if err := a.client.Unenroll(ctx); err != nil {
a.Logger.WarnCtx(
ctx,
@@ -315,12 +339,15 @@ func (a *Agent) Unenroll(ctx context.Context) error {
log.Error(err),
)
}
if err := DeleteAPIKey(a.Dir); err != nil {
return err
}
if err := clearPendingPostureBatches(a.Dir); err != nil {
return err
}
return nil
}
@@ -333,6 +360,7 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) {
oldPostureInterval := a.cfg.PostureInterval
host := a.currentHostInfo(time.Now())
resp, err := a.client.Heartbeat(
ctx,
HeartbeatRequest{
@@ -343,9 +371,11 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) {
)
if err != nil {
a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err))
if IsUnauthorized(err) {
a.handleUnauthorized()
}
return false, false
}
@@ -355,15 +385,18 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) {
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 {
@@ -384,16 +417,19 @@ func (a *Agent) doPostures(ctx context.Context) {
}
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:
@@ -406,6 +442,7 @@ func (a *Agent) doPostures(ctx context.Context) {
notApplicableCount++
}
}
a.Logger.InfoCtx(
ctx,
"posture checks completed",
@@ -430,21 +467,27 @@ func (a *Agent) doPostures(ctx context.Context) {
},
)
}
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",
@@ -458,6 +501,7 @@ 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
@@ -468,6 +512,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) {
a.Logger.WarnCtx(ctx, "cannot load pending posture batches", log.Error(err))
return
}
if len(batches) == 0 {
a.resetPendingFlushRetry()
return
@@ -479,9 +524,11 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) {
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,
@@ -490,6 +537,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) {
log.Int("remaining_batches", len(batches)-i),
log.Duration("retry_in", retryIn),
)
return
}
}
@@ -498,6 +546,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) {
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)))
}
@@ -508,9 +557,11 @@ func (a *Agent) currentHostInfo(now time.Time) HostInfo {
if collector == nil {
collector = CollectHostInfo
}
a.hostInfo = collector()
a.hostInfoCollectedAt = now
}
return a.hostInfo
}
@@ -518,6 +569,7 @@ func (a *Agent) currentTime() time.Time {
if a.now != nil {
return a.now()
}
return time.Now()
}
@@ -525,9 +577,11 @@ func (a *Agent) randomInt63n(n int64) int64 {
if n <= 1 {
return 0
}
if a.randInt63n != nil {
return a.randInt63n(n)
}
return rand.Int63n(n)
}
@@ -541,9 +595,11 @@ func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration {
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
@@ -551,6 +607,7 @@ func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration {
retryIn := max(nextBase+jitter, time.Second)
a.pendingFlushRetryAt = now.Add(retryIn)
return retryIn
}
@@ -564,17 +621,21 @@ func (a *Agent) handleUnauthorized() {
if a.revoked {
return
}
a.revoked = true
if a.client != nil {
a.client.APIKey = ""
}
a.Logger.Warn("agent API returned 401, wiping local key and requiring re-enrollment")
if err := DeleteAPIKey(a.Dir); err != nil {
a.Logger.Error("cannot delete local key after 401", log.Error(err))
}
if err := clearPendingPostureBatches(a.Dir); err != nil {
a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err))
}
a.resetPendingFlushRetry()
}

View File

@@ -43,11 +43,14 @@ func darwinDiskEncryption(ctx context.Context) Result {
},
)
}
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)
}
@@ -61,40 +64,51 @@ func darwinScreenLock(ctx context.Context) Result {
"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
@@ -102,9 +116,11 @@ func darwinScreenLock(ctx context.Context) Result {
ev["delay_source"] = delaySource
}
}
if enabled {
return pass(ev)
}
return fail(ev)
}
@@ -113,20 +129,25 @@ func darwinScreenLockMode(raw string) (string, int, bool) {
if strings.Contains(lower, "immediate") {
return "immediate", 0, true
}
if strings.Contains(lower, "off") {
return "off", -1, true
}
if before, _, ok := strings.Cut(lower, "seconds"); ok {
prefix := strings.Fields(before)
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
}
@@ -140,14 +161,17 @@ func darwinFirewall(ctx context.Context) Result {
)
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,
@@ -157,14 +181,18 @@ func darwinFirewall(ctx context.Context) Result {
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)
}
@@ -173,6 +201,7 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s
consoleUser := darwinConsoleUser(ctx)
if os.Geteuid() == 0 && consoleUser != "" {
var consoleMissing CmdResult
consoleMissingSource := ""
if CommandExists("sudo") {
@@ -190,9 +219,11 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s
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
@@ -211,9 +242,11 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s
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
@@ -221,17 +254,21 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s
}
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
}
@@ -250,14 +287,17 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s
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")
@@ -267,19 +307,23 @@ 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")
@@ -300,11 +344,14 @@ func darwinTimeSync(ctx context.Context) Result {
},
)
}
on := strings.Contains(strings.ToLower(out.Stdout), "on")
ev := map[string]any{"raw": out.Stdout}
if on {
return pass(ev)
}
return fail(ev)
}
@@ -313,11 +360,13 @@ func darwinOSVersion(ctx context.Context) Result {
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)
}
@@ -337,10 +386,12 @@ func darwinAutoUpdate(ctx context.Context) Result {
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,
@@ -352,6 +403,7 @@ func darwinAutoUpdate(ctx context.Context) Result {
needsAdmin(fallback.Stderr) {
ev["error"] = errString(fallback.Err)
ev["stderr"] = fallback.Stderr
return unknown(ev)
}
@@ -378,11 +430,14 @@ func darwinPasswordPolicy(ctx context.Context) Result {
},
)
}
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)
}
@@ -396,11 +451,14 @@ func darwinRemoteLogin(ctx context.Context) Result {
},
)
}
off := strings.Contains(strings.ToLower(out.Stdout), "off")
ev := map[string]any{"raw": out.Stdout}
if off {
return pass(ev)
}
return fail(ev)
}
@@ -413,7 +471,9 @@ func darwinMalwareProtection(ctx context.Context) Result {
if _, err := os.Stat(path); err != nil {
continue
}
ev := map[string]any{"engine": "XProtect", "plist": path}
version := RunCommand(
ctx,
"defaults",
@@ -424,8 +484,10 @@ func darwinMalwareProtection(ctx context.Context) Result {
if version.Err == nil {
ev["version"] = version.Stdout
}
return pass(ev)
}
return fail(
map[string]any{
"engine": "XProtect",

View File

@@ -29,6 +29,7 @@ var (
func Register(key string, run func(context.Context) Result) {
registryMu.Lock()
defer registryMu.Unlock()
registry = append(
registry,
funcCheck{
@@ -51,5 +52,6 @@ func All() []Check {
return out[i].Key() < out[j].Key()
},
)
return out
}

View File

@@ -51,10 +51,13 @@ func RunCommand(ctx context.Context, name string, args ...string) CmdResult {
}
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()),
@@ -93,6 +96,7 @@ func isExecutableFile(path string) bool {
if err != nil || info.IsDir() {
return false
}
if runtime.GOOS == "windows" {
return true
}

View File

@@ -44,9 +44,11 @@ func (c funcCheck) Run(ctx context.Context) Result {
if r.CheckKey == "" {
r.CheckKey = c.key
}
if r.ObservedAt.IsZero() {
r.ObservedAt = time.Now().UTC()
}
return r
}
@@ -71,6 +73,7 @@ func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View File

@@ -173,6 +173,7 @@ func IsUnauthorized(err error) bool {
if !errors.As(err, &herr) {
return false
}
return herr.StatusCode == http.StatusUnauthorized
}
@@ -186,11 +187,13 @@ func (c *Client) do(
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)
}
@@ -198,6 +201,7 @@ func (c *Client) do(
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)
@@ -214,6 +218,7 @@ func (c *Client) do(
if err != nil {
return fmt.Errorf("cannot perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {

View File

@@ -64,6 +64,7 @@ func ConfigPath(dir string) string {
if dir == "" {
dir = DefaultConfigDir()
}
return filepath.Join(dir, ConfigFileName)
}
@@ -73,11 +74,14 @@ func LoadConfig(dir string) (*Config, error) {
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
}
@@ -96,12 +100,14 @@ func SaveConfig(dir string, cfg *Config) error {
}
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)

View File

@@ -50,6 +50,7 @@ func CollectHostInfo() HostInfo {
}
info.OSVersion = collectOSVersion()
info.HardwareUUID = collectHardwareUUID()
if sn := collectSerialNumber(); sn != "" {
info.SerialNumber = &sn
@@ -66,6 +67,7 @@ func hashFallbackUUID() string {
h.Write([]byte(hostname))
h.Write([]byte{0})
h.Write([]byte(mac))
return hex.EncodeToString(h.Sum(nil))
}
@@ -94,5 +96,6 @@ func firstStableMAC() string {
func runQuiet(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
out, err := cmd.Output()
return strings.TrimSpace(string(out)), err
}

View File

@@ -34,6 +34,7 @@ func collectOSVersion() string {
}
out, _ = runQuiet(ctx, "uname", "-sr")
return out
}
@@ -54,6 +55,7 @@ func collectSerialNumber() string {
defer cancel()
out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice")
return extractValue(out, "IOPlatformSerialNumber")
}
@@ -65,6 +67,7 @@ func extractValue(s, key string) string {
}
rest := s[idx:]
eq := strings.Index(rest, "=")
if eq < 0 {
return ""
@@ -73,8 +76,10 @@ func extractValue(s, key string) string {
rest = strings.TrimSpace(rest[eq+1:])
rest = strings.TrimPrefix(rest, "<")
rest = strings.TrimPrefix(rest, ">")
if strings.HasPrefix(rest, "\"") {
rest = rest[1:]
before, _, ok := strings.Cut(rest, "\"")
if !ok {
return ""

View File

@@ -61,6 +61,7 @@ func loadPendingPostureBatches(dir string) ([]pendingPostureBatch, error) {
if len(batch.Results) == 0 {
continue
}
filtered = append(filtered, batch)
}
@@ -77,6 +78,7 @@ func savePendingPostureBatches(dir string, batches []pendingPostureBatch) error
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot delete pending postures: %w", err)
}
return nil
}

View File

@@ -46,10 +46,12 @@ func TestPendingPostureQueue_EnqueueTrimsOldestBatches(t *testing.T) {
time.Unix(int64(i), 0),
)
require.NoError(t, err)
if i < maxPendingPostureBatches {
assert.Equal(t, 0, dropped)
continue
}
assert.Equal(t, 1, dropped)
}
@@ -83,6 +85,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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)
@@ -121,13 +124,16 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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()
@@ -158,6 +164,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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)
@@ -181,6 +188,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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)
@@ -201,13 +209,16 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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()
@@ -224,6 +235,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) {
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)

View File

@@ -93,6 +93,7 @@ func Install(cfg Config) error {
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 {

View File

@@ -59,12 +59,14 @@ func extractTarGzFile(archivePath, wantPath, dest string) error {
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)
@@ -82,7 +84,7 @@ func extractTarGzFile(archivePath, wantPath, dest string) error {
continue
}
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
if hdr.Typeflag != tar.TypeReg {
return fmt.Errorf("update: %s is not a regular file", wantPath)
}
@@ -97,6 +99,7 @@ func extractZipFile(archivePath, wantPath, dest string) error {
if err != nil {
return fmt.Errorf("cannot open zip: %w", err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {

View File

@@ -54,6 +54,7 @@ func LayoutFor(goos, goarch string) (AssetLayout, error) {
binary := "probo-agent"
isZip := false
ext := "tar.gz"
if goos == "windows" {
binary += ".exe"
isZip = true

View File

@@ -26,6 +26,7 @@ func copyFile(src, dst string) error {
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 {
@@ -40,11 +41,14 @@ func copyFile(src, dst string) error {
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)
}

View File

@@ -46,6 +46,7 @@ func replaceBinary(dst, src string) error {
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)

View File

@@ -135,6 +135,7 @@ func (j *jsonTimestamp) UnmarshalJSON(b []byte) error {
}
*j = jsonTimestamp(t)
return nil
}
@@ -191,6 +192,7 @@ func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) {
current := normalizeSemver(u.CurrentVersion)
var best *Release
for i := range releases {
rel := &releases[i]
if rel.Draft || rel.Prerelease {
@@ -217,10 +219,12 @@ func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) {
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
@@ -281,6 +285,7 @@ func (u *Updater) Apply(ctx context.Context, rel *Release) error {
if err != nil {
return fmt.Errorf("cannot create update workdir: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
archivePath := filepath.Join(workDir, layout.ArchiveName)
@@ -353,6 +358,7 @@ func (u *Updater) resolveVerifier() (Verifier, error) {
}
u.Verifier = v
return v, nil
}
@@ -381,6 +387,7 @@ func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) {
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())
@@ -388,6 +395,7 @@ func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) {
if err != nil {
return nil, fmt.Errorf("cannot fetch releases: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
@@ -408,6 +416,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
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())
@@ -415,6 +424,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
if err != nil {
return fmt.Errorf("cannot fetch %s: %w", src, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
@@ -423,6 +433,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
}
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)
@@ -432,6 +443,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
_ = 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)
}
@@ -440,6 +452,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
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)
@@ -537,6 +550,7 @@ func verifyChecksum(archivePath, checksumPath, archiveName string) error {
if err != nil {
return fmt.Errorf("cannot open archive: %w", err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
@@ -568,6 +582,7 @@ func readChecksum(path, archiveName string) (string, error) {
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.

View File

@@ -163,6 +163,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout,
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,
@@ -179,6 +180,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout,
"browser_download_url": base + "/download/" + checksumBundleFileName,
})
}
body := []map[string]any{
{
"tag_name": frs.tag,
@@ -187,6 +189,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout,
"assets": assets,
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
_ = version
@@ -206,6 +209,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout,
frs.server = httptest.NewServer(mux)
t.Cleanup(frs.server.Close)
return frs
}
@@ -213,9 +217,11 @@ 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)
}
@@ -246,6 +252,7 @@ func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte {
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}
@@ -268,6 +275,7 @@ func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte {
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}

View File

@@ -106,12 +106,15 @@ 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")
}
@@ -169,6 +172,7 @@ func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath stri
if err != nil {
return fmt.Errorf("cannot open artifact for verification: %w", err)
}
defer func() { _ = artifact.Close() }()
policy := verify.NewPolicy(