Add probo-agent CLI and deviceagent library

Introduce the standalone device agent binary and shared library
for enrollment, posture checks, self-update, and OS service
integration. Include build targets, module deps, and release
workflow so the agent can ship independently of server changes.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-26 09:01:08 -07:00
parent e643a3259c
commit 22e50b3f11
56 changed files with 7410 additions and 127 deletions

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
)
const (
maxExtractedFileSize = 200 * 1024 * 1024 // 200 MiB hard cap per file
)
// extractBinary extracts the agent binary at
// `<ArchiveDir>/<BinaryName>` from archivePath into workDir and
// returns the absolute path of the written binary.
func extractBinary(archivePath string, layout AssetLayout, workDir string) (string, error) {
wantPath := path.Join(layout.ArchiveDir, layout.BinaryName)
dest := filepath.Join(workDir, "probo-agent.new")
if layout.IsZip {
if err := extractZipFile(archivePath, wantPath, dest); err != nil {
return "", err
}
} else {
if err := extractTarGzFile(archivePath, wantPath, dest); err != nil {
return "", err
}
}
if _, err := os.Stat(dest); err != nil {
return "", fmt.Errorf("update: extracted binary missing: %w", err)
}
return dest, nil
}
func extractTarGzFile(archivePath, wantPath, dest string) error {
f, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("cannot open archive: %w", err)
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("cannot read gzip: %w", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("cannot read tar entry: %w", err)
}
if path.Clean(hdr.Name) != wantPath {
continue
}
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
return fmt.Errorf("update: %s is not a regular file", wantPath)
}
return writeStream(dest, tr, 0o755)
}
return fmt.Errorf("update: %s missing from archive", wantPath)
}
func extractZipFile(archivePath, wantPath, dest string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("cannot open zip: %w", err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {
if path.Clean(f.Name) != wantPath {
continue
}
if f.FileInfo().IsDir() {
return fmt.Errorf("update: %s is a directory", wantPath)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("cannot open %s in zip: %w", wantPath, err)
}
err = writeStream(dest, rc, 0o755)
_ = rc.Close()
return err
}
return fmt.Errorf("update: %s missing from archive", wantPath)
}
func writeStream(dest string, src io.Reader, mode os.FileMode) error {
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("cannot create %s: %w", dest, err)
}
if _, err := io.Copy(out, io.LimitReader(src, maxExtractedFileSize+1)); err != nil {
_ = out.Close()
return fmt.Errorf("cannot write %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("cannot close %s: %w", dest, err)
}
stat, err := os.Stat(dest)
if err != nil {
return fmt.Errorf("cannot stat %s: %w", dest, err)
}
if stat.Size() > maxExtractedFileSize {
_ = os.Remove(dest)
return fmt.Errorf("update: extracted file exceeds %d bytes", maxExtractedFileSize)
}
return nil
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"fmt"
"strings"
)
// AssetLayout describes the names used by the release pipeline for a
// (goos, goarch) combination. The fields mirror what the
// release-probo-agent.yaml workflow produces.
type AssetLayout struct {
// ArchiveName is the file name of the published archive
// (e.g. probo-agent_Linux_x86_64.tar.gz).
ArchiveName string
// ArchiveDir is the top-level directory inside the archive
// (e.g. probo-agent_Linux_x86_64).
ArchiveDir string
// BinaryName is the agent binary file name inside the archive
// (e.g. probo-agent or probo-agent.exe).
BinaryName string
// IsZip is true for Windows builds, which ship as zip archives.
// Other platforms ship as gzipped tar.
IsZip bool
}
// LayoutFor returns the asset layout for a given (goos, goarch).
//
// The mapping is the inverse of the case statements in the release
// workflow: linux/Linux, darwin/Darwin, windows/Windows, freebsd/Freebsd
// and amd64 -> x86_64 (others kept as-is).
func LayoutFor(goos, goarch string) (AssetLayout, error) {
osLabel, err := osLabel(goos)
if err != nil {
return AssetLayout{}, err
}
archLabel := archLabel(goarch)
dir := fmt.Sprintf("probo-agent_%s_%s", osLabel, archLabel)
binary := "probo-agent"
isZip := false
ext := "tar.gz"
if goos == "windows" {
binary += ".exe"
isZip = true
ext = "zip"
}
return AssetLayout{
ArchiveName: fmt.Sprintf("%s.%s", dir, ext),
ArchiveDir: dir,
BinaryName: binary,
IsZip: isZip,
}, nil
}
func osLabel(goos string) (string, error) {
switch strings.ToLower(goos) {
case "linux":
return "Linux", nil
case "darwin":
return "Darwin", nil
case "windows":
return "Windows", nil
case "freebsd":
return "Freebsd", nil
}
return "", fmt.Errorf("unsupported GOOS %q for auto-update", goos)
}
func archLabel(goarch string) string {
if goarch == "amd64" {
return "x86_64"
}
return goarch
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLayoutFor(t *testing.T) {
t.Parallel()
cases := []struct {
goos, goarch string
archive string
dir string
binary string
isZip bool
}{
{"linux", "amd64", "probo-agent_Linux_x86_64.tar.gz", "probo-agent_Linux_x86_64", "probo-agent", false},
{"linux", "arm64", "probo-agent_Linux_arm64.tar.gz", "probo-agent_Linux_arm64", "probo-agent", false},
{"darwin", "amd64", "probo-agent_Darwin_x86_64.tar.gz", "probo-agent_Darwin_x86_64", "probo-agent", false},
{"darwin", "arm64", "probo-agent_Darwin_arm64.tar.gz", "probo-agent_Darwin_arm64", "probo-agent", false},
{"windows", "amd64", "probo-agent_Windows_x86_64.zip", "probo-agent_Windows_x86_64", "probo-agent.exe", true},
{"windows", "arm64", "probo-agent_Windows_arm64.zip", "probo-agent_Windows_arm64", "probo-agent.exe", true},
{"freebsd", "amd64", "probo-agent_Freebsd_x86_64.tar.gz", "probo-agent_Freebsd_x86_64", "probo-agent", false},
{"freebsd", "arm64", "probo-agent_Freebsd_arm64.tar.gz", "probo-agent_Freebsd_arm64", "probo-agent", false},
}
for _, tc := range cases {
t.Run(
tc.goos+"/"+tc.goarch,
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor(tc.goos, tc.goarch)
require.NoError(t, err)
assert.Equal(t, tc.archive, layout.ArchiveName)
assert.Equal(t, tc.dir, layout.ArchiveDir)
assert.Equal(t, tc.binary, layout.BinaryName)
assert.Equal(t, tc.isZip, layout.IsZip)
},
)
}
t.Run(
"unsupported GOOS",
func(t *testing.T) {
t.Parallel()
_, err := LayoutFor("plan9", "amd64")
require.Error(t, err)
},
)
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
//go:build !windows
package update
import (
"fmt"
"io"
"os"
"path/filepath"
)
// replaceBinary replaces the file at dst with src.
//
// On Unix the rename is atomic: the kernel keeps the running
// executable mapped via its inode, while the destination path now
// points at the new binary on disk. The next exec (after the
// supervisor restarts the process) loads the new code.
//
// We try a same-directory rename first, then fall back to a
// copy + atomic rename when src and dst live on different
// filesystems (e.g. when /tmp is a tmpfs separate from /usr/local/bin).
func replaceBinary(dst, src string) error {
if err := os.Chmod(src, 0o755); err != nil {
return fmt.Errorf("cannot chmod new binary: %w", err)
}
if err := os.Rename(src, dst); err == nil {
return nil
}
// Cross-filesystem fallback: copy into <dst>.new, fsync,
// then rename within the destination directory.
staging := dst + ".new"
if err := copyFile(src, staging); err != nil {
return err
}
if err := os.Chmod(staging, 0o755); err != nil {
_ = os.Remove(staging)
return fmt.Errorf("cannot chmod staged binary: %w", err)
}
if err := os.Rename(staging, dst); err != nil {
_ = os.Remove(staging)
return fmt.Errorf("cannot atomically replace %s: %w", dst, err)
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("cannot open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("cannot create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return fmt.Errorf("cannot copy to %s: %w", dst, err)
}
if err := out.Sync(); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return fmt.Errorf("cannot fsync %s: %w", dst, err)
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return fmt.Errorf("cannot close %s: %w", dst, err)
}
return nil
}
// CleanupAfterRestart removes any leftover .old binary from a
// previous Windows-style swap. On Unix this is a no-op.
func CleanupAfterRestart(_ string) {}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
//go:build windows
package update
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const oldSuffix = ".old"
// replaceBinary swaps dst with src on Windows.
//
// Windows blocks deletion / replacement of the running .exe but does
// allow renaming a locked .exe out of the way. We:
//
// 1. Stage src as `<dst>.new` (same directory, so the final rename is
// just a metadata update and won't cross volumes).
// 2. Move the running binary to `<dst>.old` (NTFS lets us rename a
// locked exe).
// 3. Move `<dst>.new` into place at `<dst>`.
//
// On the next start the agent's main() calls CleanupAfterRestart to
// best-effort delete `<dst>.old`.
func replaceBinary(dst, src string) error {
staging := dst + ".new"
if err := copyFile(src, staging); err != nil {
return err
}
oldPath := dst + oldSuffix
_ = os.Remove(oldPath)
if err := os.Rename(dst, oldPath); err != nil && !errors.Is(err, os.ErrNotExist) {
_ = os.Remove(staging)
return fmt.Errorf("cannot move running binary aside: %w", err)
}
if err := os.Rename(staging, dst); err != nil {
// Try to roll back the running binary swap.
_ = os.Rename(oldPath, dst)
_ = os.Remove(staging)
return fmt.Errorf("cannot install new binary at %s: %w", dst, err)
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("cannot open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("cannot create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return fmt.Errorf("cannot copy to %s: %w", dst, err)
}
if err := out.Sync(); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return fmt.Errorf("cannot fsync %s: %w", dst, err)
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return fmt.Errorf("cannot close %s: %w", dst, err)
}
return nil
}
// CleanupAfterRestart removes the previous-version binary left behind
// by replaceBinary. Best-effort: callers ignore errors, so a still-locked
// `<exePath>.old` is fine and will be retried on the next boot.
func CleanupAfterRestart(exePath string) {
if exePath == "" {
return
}
_ = os.Remove(exePath + oldSuffix)
}

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Package update self-updates the probo-agent binary from GitHub
// Releases. The update flow is:
//
// 1. List the latest releases for the configured repo, filtering on a
// tag prefix (`probo-agent/v` by default).
// 2. Pick the highest semver newer than the agent's current version.
// 3. Download the matching archive plus checksums.txt, verify SHA-256.
// 4. Extract the archive and atomically replace the running binary.
//
// The caller is responsible for restarting the process so the OS
// service supervisor re-execs the new binary.
package update
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"golang.org/x/mod/semver"
)
const (
// DefaultRepo is the GitHub repository hosting probo-agent
// releases.
DefaultRepo = "getprobo/probo"
// DefaultTagPrefix is the tag prefix used by the agent's release
// pipeline. Releases look like `probo-agent/v0.1.0`.
DefaultTagPrefix = "probo-agent/v"
defaultAPIBaseURL = "https://api.github.com"
defaultAssetBaseURL = "https://github.com"
defaultPageSize = 30
defaultDownloadLimit = 200 * 1024 * 1024 // 200 MiB cap on archive size
checksumFileName = "checksums.txt"
checksumBundleFileName = "checksums.txt.bundle"
)
// ErrNoUpdateAvailable is returned by CheckLatest when no release
// newer than the current version exists.
var ErrNoUpdateAvailable = errors.New("no update available")
type (
// Updater self-updates the agent binary on the local host.
Updater struct {
Repo string
TagPrefix string
APIBaseURL string
AssetBaseURL string
CurrentVersion string
ExePath string
UserAgent string
HTTP *http.Client
Logger *log.Logger
// SigstoreCacheDir is the on-disk directory used by the
// default cosign Verifier to cache Sigstore TUF metadata.
// Required when Verifier is nil.
SigstoreCacheDir string
// Verifier validates the Sigstore bundle that accompanies
// every release. When nil, the default cosign Verifier is
// constructed lazily on first Apply, pinned to the
// probo-agent release workflow.
Verifier Verifier
// GOOS/GOARCH override the values used to compute the
// archive name. They default to runtime.GOOS/GOARCH and
// exist for tests.
GOOS string
GOARCH string
}
// Release describes a candidate update.
Release struct {
Version string
Tag string
AssetName string
AssetURL string
ChecksumURL string
ChecksumBundleURL string
}
githubRelease struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
PublishedAt jsonTimestamp `json:"published_at"`
}
githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
jsonTimestamp time.Time
)
func (j *jsonTimestamp) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return err
}
*j = jsonTimestamp(t)
return nil
}
// New returns an Updater with sane defaults for production use.
//
// sigstoreCacheDir is the on-disk directory used to cache Sigstore
// TUF metadata for cosign bundle verification. It MUST be writable by
// the agent. A typical value is `<agent state dir>/sigstore-cache`.
func New(currentVersion, exePath, userAgent, sigstoreCacheDir string, logger *log.Logger) *Updater {
if logger == nil {
logger = log.NewLogger(log.WithName("agent-update"))
}
return &Updater{
Repo: DefaultRepo,
TagPrefix: DefaultTagPrefix,
APIBaseURL: defaultAPIBaseURL,
AssetBaseURL: defaultAssetBaseURL,
CurrentVersion: currentVersion,
ExePath: exePath,
UserAgent: userAgent,
Logger: logger,
HTTP: defaultHTTPClient(logger),
SigstoreCacheDir: sigstoreCacheDir,
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
}
func defaultHTTPClient(logger *log.Logger) *http.Client {
return &http.Client{
Transport: httpclient.DefaultPooledTransport(
httpclient.WithLogger(logger),
httpclient.WithSSRFProtection(),
),
Timeout: 5 * time.Minute,
}
}
// CheckLatest queries GitHub for the highest semver release whose tag
// matches u.TagPrefix and is newer than u.CurrentVersion. It returns
// ErrNoUpdateAvailable when nothing newer exists.
func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) {
layout, err := LayoutFor(u.goos(), u.goarch())
if err != nil {
return nil, err
}
releases, err := u.listReleases(ctx)
if err != nil {
return nil, err
}
current := normalizeSemver(u.CurrentVersion)
var best *Release
for i := range releases {
rel := &releases[i]
if rel.Draft || rel.Prerelease {
continue
}
ver, ok := parseTag(rel.TagName, u.TagPrefix)
if !ok {
continue
}
// Skip anything that is not strictly newer than the running
// version. When running a dev build (`current` is empty)
// every published release is considered newer.
if current != "" && semver.Compare(normalizeSemver(ver), current) <= 0 {
continue
}
if best != nil && semver.Compare(normalizeSemver(ver), normalizeSemver(best.Version)) <= 0 {
continue
}
assetURL, ok := findAssetURL(rel.Assets, layout.ArchiveName)
if !ok {
continue
}
checksumURL, ok := findAssetURL(rel.Assets, checksumFileName)
if !ok {
continue
}
bundleURL, ok := findAssetURL(rel.Assets, checksumBundleFileName)
if !ok {
// Releases without a Sigstore bundle predate the
// signed-release pipeline and cannot be verified.
// Skip them so the agent never auto-installs an
// unsigned artifact.
continue
}
best = &Release{
Version: ver,
Tag: rel.TagName,
AssetName: layout.ArchiveName,
AssetURL: assetURL,
ChecksumURL: checksumURL,
ChecksumBundleURL: bundleURL,
}
}
if best == nil {
return nil, ErrNoUpdateAvailable
}
return best, nil
}
// Apply downloads the release archive, verifies the Sigstore bundle
// covering checksums.txt, verifies the SHA-256 of the archive against
// the now-trusted checksums.txt, extracts the binary to a temp
// directory, and atomically replaces u.ExePath with the new binary.
//
// Failure at *any* verification step aborts the update without
// touching the running binary.
func (u *Updater) Apply(ctx context.Context, rel *Release) error {
if rel == nil {
return errors.New("nil release")
}
if u.ExePath == "" {
return errors.New("agent executable path is empty")
}
if rel.ChecksumBundleURL == "" {
return errors.New("release is not signed (no checksums.txt.bundle)")
}
layout, err := LayoutFor(u.goos(), u.goarch())
if err != nil {
return err
}
verifier, err := u.resolveVerifier()
if err != nil {
return err
}
workDir, err := os.MkdirTemp("", "probo-agent-update-")
if err != nil {
return fmt.Errorf("cannot create update workdir: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
archivePath := filepath.Join(workDir, layout.ArchiveName)
if err := u.downloadFile(ctx, rel.AssetURL, archivePath); err != nil {
return fmt.Errorf("cannot download archive: %w", err)
}
checksumPath := filepath.Join(workDir, checksumFileName)
if err := u.downloadFile(ctx, rel.ChecksumURL, checksumPath); err != nil {
return fmt.Errorf("cannot download checksums: %w", err)
}
bundlePath := filepath.Join(workDir, checksumBundleFileName)
if err := u.downloadFile(ctx, rel.ChecksumBundleURL, bundlePath); err != nil {
return fmt.Errorf("cannot download sigstore bundle: %w", err)
}
// Anchor the trust chain: verify that the bundle attests
// checksums.txt was signed by the pinned release workflow,
// before reading anything from checksums.txt.
if err := verifier.Verify(ctx, checksumPath, bundlePath); err != nil {
return fmt.Errorf("cannot verify sigstore bundle: %w", err)
}
if err := verifyChecksum(archivePath, checksumPath, layout.ArchiveName); err != nil {
return err
}
extractedBinary, err := extractBinary(archivePath, layout, workDir)
if err != nil {
return fmt.Errorf("cannot extract archive: %w", err)
}
if err := replaceBinary(u.ExePath, extractedBinary); err != nil {
return fmt.Errorf("cannot replace agent binary: %w", err)
}
u.Logger.InfoCtx(
ctx,
"agent binary updated",
log.String("version", rel.Version),
log.String("tag", rel.Tag),
log.String("asset", rel.AssetName),
)
return nil
}
// resolveVerifier returns a non-nil Verifier, lazily building the
// default cosign-backed verifier when none was injected.
func (u *Updater) resolveVerifier() (Verifier, error) {
if u.Verifier != nil {
return u.Verifier, nil
}
if u.SigstoreCacheDir == "" {
return nil, errors.New("SigstoreCacheDir must be set for default cosign verifier")
}
v, err := NewCosignVerifier(
CosignVerifierConfig{
Repo: u.Repo,
WorkflowPath: expectedWorkflowPath,
TagPrefix: u.TagPrefix,
CacheDir: u.SigstoreCacheDir,
},
)
if err != nil {
return nil, err
}
u.Verifier = v
return v, nil
}
// listReleases returns the most recent page of releases from GitHub.
func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) {
apiBase := u.APIBaseURL
if apiBase == "" {
apiBase = defaultAPIBaseURL
}
endpoint, err := url.JoinPath(apiBase, "repos", u.Repo, "releases")
if err != nil {
return nil, fmt.Errorf("cannot build releases URL: %w", err)
}
parsed, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("cannot parse releases URL: %w", err)
}
q := parsed.Query()
q.Set("per_page", fmt.Sprintf("%d", defaultPageSize))
parsed.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot build releases request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", u.userAgent())
resp, err := u.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch releases: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("cannot fetch releases: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var out []githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, 4*1024*1024)).Decode(&out); err != nil {
return nil, fmt.Errorf("cannot decode releases: %w", err)
}
return out, nil
}
func (u *Updater) downloadFile(ctx context.Context, src, dst string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
if err != nil {
return fmt.Errorf("cannot build download request: %w", err)
}
req.Header.Set("Accept", "application/octet-stream")
req.Header.Set("User-Agent", u.userAgent())
resp, err := u.HTTP.Do(req)
if err != nil {
return fmt.Errorf("cannot fetch %s: %w", src, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("cannot fetch %s: %d %s", src, resp.StatusCode, strings.TrimSpace(string(body)))
}
tmp := dst + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("cannot create %s: %w", tmp, err)
}
if _, err := io.Copy(f, io.LimitReader(resp.Body, defaultDownloadLimit+1)); err != nil {
_ = f.Close()
return fmt.Errorf("cannot stream %s: %w", src, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("cannot close %s: %w", tmp, err)
}
stat, err := os.Stat(tmp)
if err != nil {
return fmt.Errorf("cannot stat %s: %w", tmp, err)
}
if stat.Size() > defaultDownloadLimit {
_ = os.Remove(tmp)
return fmt.Errorf("download %s exceeds %d bytes", src, defaultDownloadLimit)
}
if err := os.Rename(tmp, dst); err != nil {
return fmt.Errorf("cannot move %s into place: %w", tmp, err)
}
return nil
}
func (u *Updater) userAgent() string {
if u.UserAgent != "" {
return u.UserAgent
}
return "probo-agent-updater"
}
func (u *Updater) goos() string {
if u.GOOS != "" {
return u.GOOS
}
return runtime.GOOS
}
func (u *Updater) goarch() string {
if u.GOARCH != "" {
return u.GOARCH
}
return runtime.GOARCH
}
// parseTag returns the version (e.g. "0.2.0") for a tag whose value
// starts with prefix (e.g. "probo-agent/v").
func parseTag(tag, prefix string) (string, bool) {
if !strings.HasPrefix(tag, prefix) {
return "", false
}
v := strings.TrimPrefix(tag, prefix)
if v == "" {
return "", false
}
if !semver.IsValid("v" + v) {
return "", false
}
return v, true
}
// normalizeSemver returns the canonical form expected by golang.org/x/mod/semver
// (a "v" prefix), or "" when the input is empty / invalid.
func normalizeSemver(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if !semver.IsValid(v) {
return ""
}
return v
}
func findAssetURL(assets []githubAsset, name string) (string, bool) {
for _, a := range assets {
if a.Name == name {
return a.BrowserDownloadURL, true
}
}
return "", false
}
// verifyChecksum checks that the SHA-256 digest of archivePath
// matches the entry for archiveName in the checksums.txt file
// produced by `sha256sum *.tar.gz *.zip`.
func verifyChecksum(archivePath, checksumPath, archiveName string) error {
expected, err := readChecksum(checksumPath, archiveName)
if err != nil {
return err
}
f, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("cannot open archive: %w", err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("cannot hash archive: %w", err)
}
actual := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(actual, expected) {
return fmt.Errorf(
"update: checksum mismatch for %s (expected %s, got %s)",
archiveName,
expected,
actual,
)
}
return nil
}
func readChecksum(path, archiveName string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("cannot read checksums: %w", err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// `sha256sum` output is `<hex> <name>`; the GNU tool also
// supports a single-space separator and a leading `*` flag
// for binary mode. Handle both.
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := strings.TrimPrefix(fields[1], "*")
if name != archiveName {
continue
}
return strings.ToLower(fields[0]), nil
}
return "", fmt.Errorf("update: %s missing from checksums file", archiveName)
}

View File

@@ -0,0 +1,460 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
)
func TestParseTag(t *testing.T) {
t.Parallel()
cases := []struct {
tag string
prefix string
want string
ok bool
}{
{"probo-agent/v0.1.0", "probo-agent/v", "0.1.0", true},
{"probo-agent/v1.2.3", "probo-agent/v", "1.2.3", true},
{"v1.2.3", "probo-agent/v", "", false},
{"probo-agent/vlatest", "probo-agent/v", "", false},
{"probo-agent/v", "probo-agent/v", "", false},
{"unrelated/v0.1.0", "probo-agent/v", "", false},
}
for _, tc := range cases {
got, ok := parseTag(tc.tag, tc.prefix)
assert.Equal(t, tc.ok, ok, tc.tag)
assert.Equal(t, tc.want, got, tc.tag)
}
}
func TestNormalizeSemver(t *testing.T) {
t.Parallel()
assert.Equal(t, "v0.1.0", normalizeSemver("0.1.0"))
assert.Equal(t, "v1.2.3", normalizeSemver("v1.2.3"))
assert.Equal(t, "v1.2.3-alpha.1", normalizeSemver("1.2.3-alpha.1"))
assert.Equal(t, "", normalizeSemver(""))
assert.Equal(t, "", normalizeSemver("not-a-version"))
}
func TestReadChecksum(t *testing.T) {
t.Parallel()
t.Run(
"plain sha256sum output",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
content := "" +
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef probo-agent_Linux_x86_64.tar.gz\n" +
"abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd probo-agent_Darwin_arm64.tar.gz\n"
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
got, err := readChecksum(file, "probo-agent_Darwin_arm64.tar.gz")
require.NoError(t, err)
assert.Equal(t, "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", got)
},
)
t.Run(
"binary-mode flag is stripped",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
content := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef *probo-agent_Linux_x86_64.tar.gz\n"
require.NoError(t, os.WriteFile(file, []byte(content), 0o600))
got, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
require.NoError(t, err)
assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got)
},
)
t.Run(
"missing entry returns error",
func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "checksums.txt")
require.NoError(t, os.WriteFile(file, []byte("deadbeef other.tar.gz\n"), 0o600))
_, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz")
require.Error(t, err)
},
)
}
// fakeReleaseServer simulates the GitHub releases API and the
// browser_download_url asset endpoints.
type fakeReleaseServer struct {
t *testing.T
server *httptest.Server
// release plumbing
tag string
prerelease bool
draft bool
// archive plumbing
binaryContent []byte
archiveBytes []byte
checksumLine string
bundleBytes []byte
// when true, the release does not advertise a checksums.txt.bundle asset
omitBundle bool
}
func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, binary []byte) *fakeReleaseServer {
t.Helper()
archive := buildArchive(t, layout, binary)
sum := sha256.Sum256(archive)
checksum := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), layout.ArchiveName)
frs := &fakeReleaseServer{
t: t,
tag: tag,
binaryContent: binary,
archiveBytes: archive,
checksumLine: checksum,
bundleBytes: []byte("dummy-sigstore-bundle"),
}
mux := http.NewServeMux()
mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
assets := []map[string]any{
{
"name": layout.ArchiveName,
"browser_download_url": base + "/download/" + layout.ArchiveName,
},
{
"name": checksumFileName,
"browser_download_url": base + "/download/" + checksumFileName,
},
}
if !frs.omitBundle {
assets = append(assets, map[string]any{
"name": checksumBundleFileName,
"browser_download_url": base + "/download/" + checksumBundleFileName,
})
}
body := []map[string]any{
{
"tag_name": frs.tag,
"draft": frs.draft,
"prerelease": frs.prerelease,
"assets": assets,
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
_ = version
})
mux.HandleFunc("/download/"+layout.ArchiveName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(frs.archiveBytes)
})
mux.HandleFunc("/download/"+checksumFileName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(frs.checksumLine))
})
mux.HandleFunc("/download/"+checksumBundleFileName, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(frs.bundleBytes)
})
frs.server = httptest.NewServer(mux)
t.Cleanup(frs.server.Close)
return frs
}
func (f *fakeReleaseServer) URL() string { return f.server.URL }
func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
if layout.IsZip {
return buildZip(t, layout, binary)
}
return buildTarGz(t, layout, binary)
}
func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, layout.ArchiveName)
f, err := os.Create(out)
require.NoError(t, err)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: path.Join(layout.ArchiveDir, layout.BinaryName),
Mode: 0o755,
Size: int64(len(binary)),
Typeflag: tar.TypeReg,
}))
_, err = tw.Write(binary)
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gz.Close())
require.NoError(t, f.Close())
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}
func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, layout.ArchiveName)
f, err := os.Create(out)
require.NoError(t, err)
zw := zip.NewWriter(f)
w, err := zw.Create(path.Join(layout.ArchiveDir, layout.BinaryName))
require.NoError(t, err)
_, err = w.Write(binary)
require.NoError(t, err)
require.NoError(t, zw.Close())
require.NoError(t, f.Close())
data, err := os.ReadFile(out)
require.NoError(t, err)
return data
}
func newTestUpdater(server *fakeReleaseServer, currentVersion, exePath, goos, goarch string) *Updater {
return &Updater{
Repo: "getprobo/probo",
TagPrefix: DefaultTagPrefix,
APIBaseURL: server.URL(),
AssetBaseURL: server.URL(),
CurrentVersion: currentVersion,
ExePath: exePath,
UserAgent: "probo-agent-test/0.0.0",
Logger: log.NewLogger(log.WithName("update-test")),
HTTP: &http.Client{
Transport: httpclient.DefaultPooledTransport(
httpclient.WithSSRFProtection(),
httpclient.WithSSRFAllowLoopback(),
),
},
// Tests bypass the cosign verifier; production code wires
// CosignVerifier in via Updater.SigstoreCacheDir.
Verifier: AllowAllVerifier{},
GOOS: goos,
GOARCH: goarch,
}
}
func TestUpdater_CheckLatest(t *testing.T) {
t.Parallel()
t.Run(
"returns release when newer version is available",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
assert.Equal(t, "0.2.0", rel.Version)
assert.Equal(t, layout.ArchiveName, rel.AssetName)
},
)
t.Run(
"returns ErrNoUpdateAvailable when running latest",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("darwin", "arm64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("same"))
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "darwin", "arm64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
},
)
t.Run(
"skips draft and prerelease tags",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0-rc.1", "0.2.0-rc.1", layout, []byte("rc"))
fake.prerelease = true
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable)
},
)
t.Run(
"dev build always sees update available",
func(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("rel"))
u := newTestUpdater(fake, "dev", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
assert.Equal(t, "0.1.0", rel.Version)
},
)
}
func TestUpdater_Apply(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("apply test exercises the unix swap path; windows has its own .old shuffle")
}
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
require.NoError(t, u.Apply(context.Background(), rel))
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("new-binary"), got)
stat, err := os.Stat(exePath)
require.NoError(t, err)
assert.NotZero(t, stat.Mode().Perm()&0o100, "new binary should be executable")
}
func TestUpdater_CheckLatest_SkipsUnsignedRelease(t *testing.T) {
t.Parallel()
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new"))
fake.omitBundle = true
u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64")
_, err = u.CheckLatest(context.Background())
assert.ErrorIs(t, err, ErrNoUpdateAvailable, "release without a sigstore bundle must be ignored")
}
func TestUpdater_Apply_RejectsBadSignature(t *testing.T) {
t.Parallel()
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
u.Verifier = rejectAllVerifier{err: fmt.Errorf("test: signer identity mismatch")}
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
err = u.Apply(context.Background(), rel)
require.Error(t, err)
assert.Contains(t, err.Error(), "sigstore")
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("old-binary"), got, "rejected signature must not touch the running binary")
}
func TestUpdater_Apply_RejectsCorruptedArchive(t *testing.T) {
t.Parallel()
dir := t.TempDir()
exePath := filepath.Join(dir, "probo-agent")
require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755))
layout, err := LayoutFor("linux", "amd64")
require.NoError(t, err)
fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary"))
// Corrupt the archive without updating checksums.
fake.archiveBytes = append(fake.archiveBytes, 0xff)
u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64")
rel, err := u.CheckLatest(context.Background())
require.NoError(t, err)
err = u.Apply(context.Background(), rel)
require.Error(t, err)
assert.Contains(t, err.Error(), "checksum mismatch")
got, err := os.ReadFile(exePath)
require.NoError(t, err)
assert.Equal(t, []byte("old-binary"), got, "corrupted update must not touch the running binary")
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"context"
"fmt"
"os"
"regexp"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore-go/pkg/tuf"
"github.com/sigstore/sigstore-go/pkg/verify"
)
const (
// expectedSignerIssuer is the OIDC issuer Fulcio embeds in the
// signing certificate when the workflow uses GitHub Actions'
// OIDC token. This is the public-good Sigstore configuration.
expectedSignerIssuer = "https://token.actions.githubusercontent.com"
// expectedWorkflowPath is the path of the release workflow that
// is allowed to produce signed probo-agent artifacts. Anything
// signed by a different workflow (or a workflow run outside of
// a tagged commit) is rejected.
expectedWorkflowPath = ".github/workflows/release-probo-agent.yaml"
)
// Verifier verifies that a Sigstore bundle (`checksums.txt.bundle`)
// attests an artifact (`checksums.txt`) was produced by the expected
// signer identity. Implementations MUST hard-fail on any error;
// callers do not interpret the error type.
type Verifier interface {
// Verify returns nil iff bundlePath is a valid Sigstore bundle
// for the artifact at artifactPath, and the signer identity
// matches the verifier's pinned issuer / SAN regex.
Verify(ctx context.Context, artifactPath, bundlePath string) error
}
// AllowAllVerifier accepts every input. It exists strictly for tests
// of the surrounding download / extract pipeline. Production callers
// must wire a real Verifier (e.g. CosignVerifier).
type AllowAllVerifier struct{}
// Verify always returns nil.
func (AllowAllVerifier) Verify(_ context.Context, _, _ string) error { return nil }
// rejectAllVerifier is exposed for tests that need to assert Apply
// hard-fails on signature problems.
type rejectAllVerifier struct{ err error }
func (v rejectAllVerifier) Verify(_ context.Context, _, _ string) error { return v.err }
// CosignVerifier verifies cosign sign-blob bundles using sigstore-go
// against the Sigstore public-good trust root.
//
// The verifier pins the signer identity to the probo-agent release
// workflow on a tagged commit:
//
// issuer: https://token.actions.githubusercontent.com
// SAN: https://github.com/<repo>/<workflow>@refs/tags/<tag-prefix><version>
//
// where <repo>, <workflow> and <tag-prefix> default to the values
// hard-coded in the release pipeline. Both fields can be overridden
// for testing or for repository forks.
type CosignVerifier struct {
Issuer string
SANRegex string
trustedRoot *root.TrustedRoot
}
// CosignVerifierConfig configures a CosignVerifier.
type CosignVerifierConfig struct {
// Repo identifies the GitHub repository (e.g. "getprobo/probo").
Repo string
// WorkflowPath is the path within the repo to the workflow file
// allowed to produce signed releases.
WorkflowPath string
// TagPrefix is the tag prefix the release workflow signs against
// (e.g. "probo-agent/v"). The verifier matches anything after
// this prefix.
TagPrefix string
// CacheDir is the on-disk directory used to cache the Sigstore
// TUF metadata. Required.
CacheDir string
}
// NewCosignVerifier loads the Sigstore public-good trust root via
// TUF (cached under cfg.CacheDir) and returns a Verifier that pins
// signatures to the configured GitHub Actions workflow on a tagged
// commit.
func NewCosignVerifier(cfg CosignVerifierConfig) (*CosignVerifier, error) {
if cfg.Repo == "" {
return nil, fmt.Errorf("update: cosign verifier requires Repo")
}
if cfg.WorkflowPath == "" {
cfg.WorkflowPath = expectedWorkflowPath
}
if cfg.TagPrefix == "" {
cfg.TagPrefix = DefaultTagPrefix
}
if cfg.CacheDir == "" {
return nil, fmt.Errorf("update: cosign verifier requires CacheDir")
}
if err := os.MkdirAll(cfg.CacheDir, 0o700); err != nil {
return nil, fmt.Errorf("cannot create sigstore cache dir: %w", err)
}
opts := tuf.DefaultOptions()
opts.CachePath = cfg.CacheDir
tufClient, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("cannot init sigstore TUF client: %w", err)
}
trustedRoot, err := root.GetTrustedRoot(tufClient)
if err != nil {
return nil, fmt.Errorf("cannot load sigstore trusted root: %w", err)
}
sanRegex := buildSANRegex(cfg.Repo, cfg.WorkflowPath, cfg.TagPrefix)
return &CosignVerifier{
Issuer: expectedSignerIssuer,
SANRegex: sanRegex,
trustedRoot: trustedRoot,
}, nil
}
// Verify validates that bundlePath attests artifactPath was signed by
// the expected GitHub Actions workflow on a tagged release.
func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath string) error {
b, err := bundle.LoadJSONFromPath(bundlePath)
if err != nil {
return fmt.Errorf("cannot load sigstore bundle: %w", err)
}
identity, err := verify.NewShortCertificateIdentity(v.Issuer, "", "", v.SANRegex)
if err != nil {
return fmt.Errorf("cannot build signer identity: %w", err)
}
sev, err := verify.NewVerifier(
v.trustedRoot,
verify.WithSignedCertificateTimestamps(1),
verify.WithTransparencyLog(1),
verify.WithObserverTimestamps(1),
)
if err != nil {
return fmt.Errorf("cannot build sigstore verifier: %w", err)
}
artifact, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("cannot open artifact for verification: %w", err)
}
defer func() { _ = artifact.Close() }()
policy := verify.NewPolicy(
verify.WithArtifact(artifact),
verify.WithCertificateIdentity(identity),
)
if _, err := sev.Verify(b, policy); err != nil {
return fmt.Errorf("sigstore verification failed: %w", err)
}
return nil
}
func buildSANRegex(repo, workflowPath, tagPrefix string) string {
return `^https://github\.com/` +
regexp.QuoteMeta(repo) +
`/` +
regexp.QuoteMeta(workflowPath) +
`@refs/tags/` +
regexp.QuoteMeta(tagPrefix) +
`.+$`
}