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:
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