Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SCOOP_TOKEN: ${{ secrets.SCOOP_TOKEN }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
ASOBI_SIGNING_KEY: ${{ secrets.ASOBI_SIGNING_KEY }}
13 changes: 13 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ checksum:
name_template: checksums.txt
algorithm: sha256

# Sign checksums.txt with the release ed25519 key (ASOBI_SIGNING_KEY secret, a
# PEM private key). `asobi upgrade` verifies checksums.txt.sig against the
# embedded public key, so a GitHub/CDN compromise alone cannot forge an upgrade.
# The raw signature is produced with openssl and read by Go's crypto/ed25519.
signs:
- id: ed25519
artifacts: checksum
signature: "${artifact}.sig"
cmd: ./scripts/sign-checksums.sh
args:
- "${artifact}"
- "${signature}"

release:
github:
owner: widgrensit
Expand Down
12 changes: 12 additions & 0 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/widgrensit/asobi-cli/internal/dev"
"github.com/widgrensit/asobi-cli/internal/scaffold"
"github.com/widgrensit/asobi-cli/internal/template"
"github.com/widgrensit/asobi-cli/internal/update"
)

const defaultSaasURL = "https://console.asobi.dev"
Expand Down Expand Up @@ -70,6 +71,8 @@ func main() {
cmdHealth()
case "config":
cmdConfig()
case "upgrade":
cmdUpgrade()
case "version", "--version", "-v":
cmdVersion()
case "help", "--help", "-h":
Expand All @@ -79,6 +82,8 @@ func main() {
usage()
os.Exit(1)
}

update.Notify(version, os.Args[1], os.Stderr)
}

func usage() {
Expand Down Expand Up @@ -106,6 +111,7 @@ Usage:
asobi health [env] [--game <slug>] Check engine health (of an environment)
asobi config set <k> <v> Set config (url, api_key, saas_url)
asobi config show Show current config
asobi upgrade Update asobi to the latest release
asobi version Show version, commit, and build date
asobi help Show this help

Expand All @@ -126,6 +132,12 @@ func cmdVersion() {
fmt.Printf(" built: %s\n", date)
}

func cmdUpgrade() {
if err := update.SelfUpgrade(version, os.Stdout); err != nil {
fatal("%v", err)
}
}

// --- Login/Logout/Whoami ---

func cmdLogin() {
Expand Down
32 changes: 32 additions & 0 deletions internal/update/signing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package update

import (
"crypto/ed25519"
"encoding/hex"
"fmt"
)

// signingPublicKey is the raw 32-byte ed25519 public key whose private half
// signs checksums.txt for every release (held as the ASOBI_SIGNING_KEY CI
// secret, never in the repo). Because the asset is verified against
// checksums.txt and checksums.txt is verified against this key, a GitHub or CDN
// compromise alone cannot forge an upgrade. Rotating the key requires shipping a
// release whose checksums are signed by the new key. Var, not const, so tests
// substitute an ephemeral key.
var signingPublicKey = "d96d1bd8642b31937478086325c9b23c42dacddce866e55654e1e2ebbdf1713a"

// verifySignature fails closed unless sig is a valid ed25519 signature of
// checksums by the release signing key.
func verifySignature(checksums, sig []byte) error {
pub, err := hex.DecodeString(signingPublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("invalid embedded signing key")
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("checksums signature has wrong size (%d bytes)", len(sig))
}
if !ed25519.Verify(ed25519.PublicKey(pub), checksums, sig) {
return fmt.Errorf("checksums signature does not verify against the release signing key")
}
return nil
}
34 changes: 34 additions & 0 deletions internal/update/signing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package update

import (
"crypto/ed25519"
"encoding/hex"
"testing"
)

func TestVerifySignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
old := signingPublicKey
signingPublicKey = hex.EncodeToString(pub)
defer func() { signingPublicKey = old }()

checksums := []byte("abc123 asobi_linux_amd64.tar.gz\n")
sig := ed25519.Sign(priv, checksums)

if err := verifySignature(checksums, sig); err != nil {
t.Errorf("valid signature rejected: %v", err)
}
if err := verifySignature([]byte("tampered checksums"), sig); err == nil {
t.Error("tampered checksums must fail signature")
}
if err := verifySignature(checksums, sig[:len(sig)-1]); err == nil {
t.Error("wrong-size signature must fail")
}
_, otherPriv, _ := ed25519.GenerateKey(nil)
if err := verifySignature(checksums, ed25519.Sign(otherPriv, checksums)); err == nil {
t.Error("signature from a different key must fail")
}
}
194 changes: 194 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Package update checks for and installs newer asobi CLI releases. The version
// check (Notify) is best-effort and never blocks or fails a command; the
// self-upgrade (see upgrade.go) verifies downloads against the release
// checksums before replacing the running binary.
package update

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/widgrensit/asobi-cli/internal/config"
)

const repo = "widgrensit/asobi-cli"

const installCmd = "curl -fsSL https://raw.githubusercontent.com/widgrensit/asobi-cli/main/install.sh | sh"

// checkInterval is how long a cached version-check result is trusted before we
// hit the network again.
const checkInterval = 24 * time.Hour

// versionRe bounds the shape of a release tag before it is trusted in a notice
// or interpolated into a download URL.
var versionRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+([-+][0-9A-Za-z.\-]+)?$`)

func validVersion(s string) bool {
return versionRe.MatchString(strings.TrimSpace(s))
}

// Overridable in tests.
var (
apiBaseURL = "https://api.github.com"
releasesBaseURL = "https://github.com/" + repo + "/releases/download"
httpClient = &http.Client{Timeout: 3 * time.Second}
)

// Notify prints a one-line notice to w when a newer release than current
// exists. It is best-effort: it consults a ~/.asobi cache first, refreshes at
// most once per checkInterval, and stays silent on any error, opt-out, in CI,
// or for a dev build. cmd is the invoked subcommand, used to skip commands
// where a notice is noise (version, upgrade, help).
func Notify(current, cmd string, w io.Writer) {
if !shouldCheck(current, cmd) {
return
}
latest, err := cachedOrFetch()
if err != nil || latest == "" {
return
}
if IsNewer(latest, current) {
fmt.Fprintf(w, "\nA new asobi is available: %s (you have %s).\nUpgrade: asobi upgrade (or: %s)\n",
latest, normalize(current), installCmd)
}
}

func shouldCheck(current, cmd string) bool {
switch {
case os.Getenv("ASOBI_NO_UPDATE_CHECK") != "":
return false
case os.Getenv("CI") != "":
return false
case current == "" || current == "dev":
return false
}
switch cmd {
case "version", "--version", "-v", "upgrade", "help", "--help", "-h":
return false
}
return true
}

type cache struct {
CheckedAt time.Time `json:"checked_at"`
Latest string `json:"latest"`
}

func cachePath() string {
return filepath.Join(config.Dir(), "version_check.json")
}

// cachedOrFetch returns the latest known release tag, using the cache when it
// is fresh and otherwise refreshing it from the releases API.
func cachedOrFetch() (string, error) {
if c, ok := readCache(); ok && time.Since(c.CheckedAt) < checkInterval && validVersion(c.Latest) {
return c.Latest, nil
}
latest, err := LatestReleaseTag(context.Background())
if err != nil {
return "", err
}
writeCache(cache{CheckedAt: time.Now(), Latest: latest})
return latest, nil
}

func readCache() (cache, bool) {
data, err := os.ReadFile(cachePath())
if err != nil {
return cache{}, false
}
var c cache
if err := json.Unmarshal(data, &c); err != nil {
return cache{}, false
}
return c, true
}

func writeCache(c cache) {
data, err := json.Marshal(c)
if err != nil {
return
}
if err := os.MkdirAll(config.Dir(), 0o700); err != nil {
return
}
_ = os.WriteFile(cachePath(), data, 0o600)
}

// LatestReleaseTag returns the tag_name of the newest published release.
func LatestReleaseTag(ctx context.Context) (string, error) {
url := fmt.Sprintf("%s/repos/%s/releases/latest", apiBaseURL, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "asobi-cli")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("releases API returned %s", resp.Status)
}
var rel struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rel); err != nil {
return "", err
}
if !validVersion(rel.TagName) {
return "", fmt.Errorf("invalid release tag %q", rel.TagName)
}
return rel.TagName, nil
}

// IsNewer reports whether latest is a strictly higher version than current.
func IsNewer(latest, current string) bool {
return CompareVersions(latest, current) > 0
}

// CompareVersions compares two vMAJOR.MINOR.PATCH strings (a leading "v" is
// optional), returning -1, 0 or 1. Any pre-release/build suffix is ignored;
// unparseable components sort as 0.
func CompareVersions(a, b string) int {
as, bs := parse(a), parse(b)
for i := 0; i < 3; i++ {
if as[i] != bs[i] {
if as[i] < bs[i] {
return -1
}
return 1
}
}
return 0
}

func parse(v string) [3]int {
v = normalize(v)
if i := strings.IndexAny(v, "-+"); i >= 0 {
v = v[:i]
}
var out [3]int
for i, part := range strings.SplitN(v, ".", 3) {
if i > 2 {
break
}
out[i], _ = strconv.Atoi(part)
}
return out
}

func normalize(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
Loading