trusted.pub now holds the release signing key, so self-upgrade, agent upgrade and node onboarding refuse a release whose checksums are not signed with it. Onboarding fetched the release on the node and compared it with a checksum from the same server; the control plane now fetches it for the node's architecture with release.Fetch, checks signature and checksum, and hands the bytes over. Release addresses must be https (loopback excepted). LLMGW_RELEASE_KEY overrides the key only in development builds. The CI release job fails instead of publishing an unsigned release while a key is built in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
246 lines
7.2 KiB
Go
246 lines
7.2 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"
|
|
"net/http"
|
|
"net/url"
|
|
"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 err := CheckBase(base); err != nil {
|
|
return "", err
|
|
}
|
|
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
|
|
}
|
|
|
|
// CheckBase refuses a release address a binary should not come from: one
|
|
// over plain HTTP, where anyone on the path can swap the bytes and, without a
|
|
// signing key, the checksums with them. Loopback is allowed, for a mirror on
|
|
// this machine and for tests.
|
|
func CheckBase(base string) error {
|
|
u, err := url.Parse(base)
|
|
if err != nil || u.Host == "" {
|
|
return fmt.Errorf("%q is not a release address", base)
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
return nil
|
|
case "http":
|
|
if ip := net.ParseIP(u.Hostname()); (ip != nil && ip.IsLoopback()) || u.Hostname() == "localhost" {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("releases are fetched over https, and %s is not", base)
|
|
}
|
|
|
|
// maxBinary bounds a release binary read into memory.
|
|
const maxBinary = 512 << 20
|
|
|
|
// Fetch downloads a release binary for a platform and checks it exactly as
|
|
// Install does — signature when this build trusts a key, then checksum —
|
|
// returning its bytes: for a machine that installs it on another, which
|
|
// then runs nothing that was not checked here.
|
|
func Fetch(ctx context.Context, client *http.Client, base, version, goos, goarch string) ([]byte, error) {
|
|
if !Tag.MatchString(version) {
|
|
return nil, fmt.Errorf("%q is not a release version", version)
|
|
}
|
|
if err := CheckBase(base); err != nil {
|
|
return nil, err
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Minute}
|
|
}
|
|
asset := AssetName(version, goos, goarch)
|
|
root := strings.TrimSuffix(base, "/") + "/" + version + "/"
|
|
want, err := checksum(ctx, client, root, asset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
got, err := download(ctx, client, root+asset, &limitWriter{w: &buf, n: maxBinary})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if got != want {
|
|
return nil, fmt.Errorf("%s does not match the release's checksum (got %s, expected %s)",
|
|
asset, got[:12], want[:12])
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// limitWriter refuses to grow past n bytes.
|
|
type limitWriter struct {
|
|
w io.Writer
|
|
n int64
|
|
}
|
|
|
|
func (l *limitWriter) Write(p []byte) (int, error) {
|
|
if int64(len(p)) > l.n {
|
|
return 0, fmt.Errorf("the release binary is larger than %d bytes", maxBinary)
|
|
}
|
|
l.n -= int64(len(p))
|
|
return l.w.Write(p)
|
|
}
|
|
|
|
// 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()
|
|
}
|