Files
llmgw/internal/release/release.go
T
Ilker ManapandClaude Opus 5.5 623cdb5ba3
CI / test (push) Successful in 4m6s
Sign releases, and install only signed ones once a key is trusted
ed25519 over checksums.txt: llmgw release keygen makes the pair, CI signs
with the RELEASE_SIGNING_KEY secret when it is set, and release.Install
refuses an unsigned or wrongly signed release when the build embeds a
trusted key (internal/release/trusted.pub, empty for now) or
LLMGW_RELEASE_KEY names one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:03:48 +02:00

172 lines
5.0 KiB
Go

// Package release fetches and installs llmgw's own release binaries.
//
// One implementation for everywhere a binary is replaced — the control plane
// upgrading itself, and an agent told to upgrade by it — so the checks are
// the same on both: the release's own checksum is verified before anything is
// installed, the old binary is kept beside the new one, and the swap is a
// rename, so a machine is never left without a binary that runs.
package release
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// DefaultBase is where CI publishes releases.
const DefaultBase = "https://gitea.mynodes.xyz/ilker/llmgw/releases/download"
// Tag is a version CI published: v0.10.4, or a release candidate v0.10.4-rc.2.
var Tag = regexp.MustCompile(`^v\d+\.\d+\.\d+(-rc\.\d+)?$`)
// AssetName is the release file for a platform.
func AssetName(version, goos, goarch string) string {
return "llmgw-" + version + "-" + goos + "-" + goarch
}
// Install fetches version for this platform from base, checks it against the
// release's checksums, and puts it at target, keeping the old one as
// target.bak-pre-<version>. It returns the backup's path.
func Install(ctx context.Context, client *http.Client, base, version, goos, goarch, target string) (string, error) {
if !Tag.MatchString(version) {
return "", fmt.Errorf("%q is not a release version", version)
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Minute}
}
target, err := filepath.EvalSymlinks(target)
if err != nil {
return "", err
}
dir := filepath.Dir(target)
asset := AssetName(version, goos, goarch)
root := strings.TrimSuffix(base, "/") + "/" + version + "/"
want, err := checksum(ctx, client, root, asset)
if err != nil {
return "", err
}
// Beside the target, so the final rename is on one filesystem and atomic.
tmp, err := os.CreateTemp(dir, ".llmgw-"+version+"-*")
if err != nil {
return "", err
}
defer os.Remove(tmp.Name())
got, err := download(ctx, client, root+asset, tmp)
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil {
return "", err
}
if got != want {
return "", fmt.Errorf("%s does not match the release's checksum (got %s, expected %s)",
asset, got[:12], want[:12])
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return "", err
}
backup := target + ".bak-pre-" + version
if err := copyFile(target, backup); err != nil {
return "", fmt.Errorf("keeping the old binary: %w", err)
}
if err := os.Rename(tmp.Name(), target); err != nil {
return "", err
}
return backup, nil
}
// checksum reads the release's checksum for asset — after checking the
// checksums' signature, when this build trusts a signing key. A release with
// no signature is then refused rather than taken on its checksums: a missing
// signature is exactly what replacing a release would look like.
func checksum(ctx context.Context, client *http.Client, root, asset string) (string, error) {
body, err := get(ctx, client, root+"checksums.txt")
if err != nil {
return "", err
}
defer body.Close()
sums, err := io.ReadAll(io.LimitReader(body, 1<<20))
if err != nil {
return "", err
}
if key := TrustedKey(); key != "" {
sigBody, err := get(ctx, client, root+SignatureName)
if err != nil {
return "", fmt.Errorf("this build only installs signed releases, and %s could not be read: %w", SignatureName, err)
}
sig, err := io.ReadAll(io.LimitReader(sigBody, 4096))
sigBody.Close()
if err != nil {
return "", err
}
if err := Verify(key, sums, string(sig)); err != nil {
return "", err
}
}
sc := bufio.NewScanner(bytes.NewReader(sums))
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) == 2 && f[1] == asset && len(f[0]) == 64 {
return f[0], nil
}
}
return "", fmt.Errorf("the release's checksums list no %s", asset)
}
func download(ctx context.Context, client *http.Client, url string, dst io.Writer) (string, error) {
body, err := get(ctx, client, url)
if err != nil {
return "", err
}
defer body.Close()
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(dst, h), body); err != nil {
return "", fmt.Errorf("downloading %s: %w", url, err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func get(ctx context.Context, client *http.Client, url string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("fetching %s: %s", url, resp.Status)
}
return resp.Body, nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src) //nolint:gosec // our own binary
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec // beside it
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}