// 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-. 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. // // With a signing key built in, plain HTTP is allowed too: the checksums are // then checked against a key no server holds, so what carries them adds // nothing, and an offline site's mirror serves releases over the plain HTTP // it serves weights on. 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 } if TrustedKey() != "" { 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. func checksum(ctx context.Context, client *http.Client, root, asset string) (string, error) { c, err := fetchChecksums(ctx, client, root) if err != nil { return "", err } return c.For(asset) } // Checksums are a release's checksums.txt and its signature, as published. type Checksums struct { Text []byte // Signature is checksums.txt.sig; empty for a release read by a build // that trusts no key. Signature string } // FetchChecksums reads a release's checksums and their signature, checked // as Install checks them: for a machine that keeps them — in an offline // bundle — to be checked again where they are used. func FetchChecksums(ctx context.Context, client *http.Client, base, version string) (Checksums, error) { if !Tag.MatchString(version) { return Checksums{}, fmt.Errorf("%q is not a release version", version) } if err := CheckBase(base); err != nil { return Checksums{}, err } if client == nil { client = &http.Client{Timeout: 10 * time.Minute} } return fetchChecksums(ctx, client, strings.TrimSuffix(base, "/")+"/"+version+"/") } // fetchChecksums reads checksums.txt under root, and its 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 fetchChecksums(ctx context.Context, client *http.Client, root string) (Checksums, error) { body, err := get(ctx, client, root+"checksums.txt") if err != nil { return Checksums{}, err } defer body.Close() sums, err := io.ReadAll(io.LimitReader(body, 1<<20)) if err != nil { return Checksums{}, err } c := Checksums{Text: sums} if TrustedKey() == "" { return c, nil } sigBody, err := get(ctx, client, root+SignatureName) if err != nil { return Checksums{}, 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 Checksums{}, err } c.Signature = string(sig) return c, c.Verify() } // Verify checks the checksums against the key this build trusts. With none // they are taken as they are, as every release before signing was. func (c Checksums) Verify() error { key := TrustedKey() if key == "" { return nil } if strings.TrimSpace(c.Signature) == "" { return fmt.Errorf("this build only installs signed releases, and these checksums carry no %s", SignatureName) } return Verify(key, c.Text, c.Signature) } // For is the checksum the release gives for one asset. func (c Checksums) For(asset string) (string, error) { sc := bufio.NewScanner(bytes.NewReader(c.Text)) 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() }