Files
aego/update/source.go
T
sanspie d436311f69 Stage 11i4: signed self-updates for Aego and exported games (D362)
update core: signed release.json (Ed25519) per channel, ETag check, resumable download with sha256, Auto/Ask/Off policy, appliers for the install.sh layout, AppImage, NSIS installer (keeps the replaced setup.exe for Restore) and .app, trial run with rollback.
Editor and launcher: banner, status bar, Help commands, Task Center download, install on restart.
CLI: aego update, aego release keygen|manifest, aego export --release-json.
Games: modules/update with update { url publicKey }, settings and the aego/update script API.
release.sh refuses to publish without AEGO_RELEASE_KEY and AEGO_UPDATE_KEY; install.sh lays out versions; make test-update runs a real v1 -> v2 update.
2026-10-26 19:36:15 +03:00

268 lines
6.1 KiB
Go

package update
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const (
// CheckTimeout bounds a check, so a slow server never holds the editor or the game.
CheckTimeout = 3 * time.Second
// ReleaseFile and SigFile are what a channel publishes.
ReleaseFile = "release.json"
SigFile = "release.json.sig"
)
// Source is where releases come from: <BaseURL>/<channel>/release.json and its signature. Aego uses its Gitea releases, a game its own server.
type Source struct {
BaseURL string
Channel Channel
// Key is the base64 public key; empty uses PublicKey.
Key string
// Cache keeps the last release.json and its ETag, so an unchanged release costs a 304.
Cache string
Client *http.Client
}
func (s *Source) client() *http.Client {
if s.Client != nil {
return s.Client
}
return &http.Client{Timeout: CheckTimeout}
}
func (s *Source) url(file string) string {
ch := s.Channel
if ch == "" {
ch = Stable
}
return strings.TrimSuffix(s.BaseURL, "/") + "/" + string(ch) + "/" + file
}
// Check fetches the channel's release.json and verifies its signature; nothing is trusted without it.
func (s *Source) Check(ctx context.Context) (*Release, error) {
key := s.Key
if key == "" {
key = PublicKey
}
pub, err := ParseKey(key)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, CheckTimeout)
defer cancel()
data, err := s.fetchRelease(ctx)
if err != nil {
return nil, err
}
sig, _, err := s.get(ctx, s.url(SigFile), "")
if err != nil {
return nil, err
}
return Verify(data, sig, pub)
}
// fetchRelease is release.json, from the cache when the server says it has not changed.
func (s *Source) fetchRelease(ctx context.Context) ([]byte, error) {
body, etag := s.cached()
data, tag, err := s.get(ctx, s.url(ReleaseFile), etag)
if errors.Is(err, errNotModified) && body != nil {
return body, nil
}
if err != nil {
return nil, err
}
s.store(data, tag)
return data, nil
}
var errNotModified = errors.New("not modified")
func (s *Source) get(ctx context.Context, url, etag string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := s.client().Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotModified:
return nil, "", errNotModified
case http.StatusNotFound:
return nil, "", fmt.Errorf("no %s release at %s", s.Channel, url)
default:
return nil, "", fmt.Errorf("%s: %s", url, resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return data, resp.Header.Get("ETag"), err
}
func (s *Source) cacheFiles() (string, string) {
if s.Cache == "" {
return "", ""
}
name := string(s.Channel)
if name == "" {
name = string(Stable)
}
return filepath.Join(s.Cache, name+".json"), filepath.Join(s.Cache, name+".etag")
}
func (s *Source) cached() ([]byte, string) {
body, tag := s.cacheFiles()
if body == "" {
return nil, ""
}
data, err := os.ReadFile(body)
if err != nil {
return nil, ""
}
etag, _ := os.ReadFile(tag)
return data, strings.TrimSpace(string(etag))
}
func (s *Source) store(data []byte, etag string) {
body, tag := s.cacheFiles()
if body == "" || etag == "" {
return
}
if os.MkdirAll(s.Cache, 0o755) == nil {
_ = os.WriteFile(body, data, 0o644)
_ = os.WriteFile(tag, []byte(etag), 0o644)
}
}
// Progress reports a download: bytes done of total, total 0 when unknown.
type Progress func(done, total int64)
// Download fetches an asset into dir, resuming a partial file, and checks its sha256; a mismatch deletes the file.
func Download(ctx context.Context, client *http.Client, a *Asset, dir string, progress Progress) (string, error) {
if a.SHA256 == "" {
return "", ErrChecksum
}
if client == nil {
client = &http.Client{}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
name := filepath.Base(a.URL)
if i := strings.IndexAny(name, "?#"); i >= 0 {
name = name[:i]
}
final := filepath.Join(dir, name)
if ok, _ := matches(final, a.SHA256); ok {
return final, nil
}
part := final + ".part"
f, err := os.OpenFile(part, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return "", err
}
defer f.Close()
have, err := f.Seek(0, io.SeekEnd)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
if err != nil {
return "", err
}
if have > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", have))
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusPartialContent:
case http.StatusOK:
if have > 0 {
if err := f.Truncate(0); err != nil {
return "", err
}
have = 0
}
default:
return "", fmt.Errorf("%s: %s", a.URL, resp.Status)
}
total := a.Size
buf := make([]byte, 64<<10)
done := have
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, err := f.Write(buf[:n]); err != nil {
return "", err
}
done += int64(n)
if progress != nil {
progress(done, total)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return "", rerr
}
}
if err := f.Close(); err != nil {
return "", err
}
if ok, err := matches(part, a.SHA256); err != nil || !ok {
os.Remove(part)
if err != nil {
return "", err
}
return "", ErrChecksum
}
if err := os.Rename(part, final); err != nil {
return "", err
}
return final, nil
}
func matches(path, sum string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false, err
}
return strings.EqualFold(hex.EncodeToString(h.Sum(nil)), strings.TrimSpace(sum)), nil
}
// Sum is the sha256 of a file, as release.json records it.
func Sum(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
return hex.EncodeToString(h.Sum(nil)), n, err
}