- internal/config: .env-style config file (gpu-turnstile.env next to the exe, -config flag or GPU_TURNSTILE_CONFIG); process env overrides file. - internal/service: Windows service via golang.org/x/sys/windows/svc — graceful SCM stop, 'service install/remove' commands, restart-on-failure recovery (also applies staged updates). First external dependency, Windows-only; Linux/Docker build unaffected (go.mod stays at 1.23). - internal/update: polls the Gitea releases API, verifies the Ed25519 signature of the downloaded binary against an embedded public key (openssl-signed by CI), swaps it in next to the running exe, and once the GPU lock is idle exits with code 3 so service recovery restarts onto the new version. Dev builds and empty pubkey never update. - CI: tag builds additionally produce gpu-turnstile.exe + .sig + .sha256 attached to a Gitea release. - LOG_FILE env var so the service has somewhere to log.
187 lines
4.9 KiB
Go
187 lines
4.9 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// fakeGitea serves a Gitea-flavored releases API with one release.
|
|
type fakeGitea struct {
|
|
srv *httptest.Server
|
|
pubPEM string
|
|
asset []byte
|
|
tag string
|
|
tamper bool
|
|
noSig bool
|
|
}
|
|
|
|
func newFakeGitea(t *testing.T, tag string, assetContent []byte) *fakeGitea {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
der, err := x509.MarshalPKIXPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeGitea{
|
|
pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})),
|
|
asset: assetContent,
|
|
tag: tag,
|
|
}
|
|
sign := func() []byte { return ed25519.Sign(priv, f.asset) }
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/o/r/releases/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
assets := []map[string]string{
|
|
{"name": "gpu-turnstile.exe", "browser_download_url": f.srv.URL + "/dl/exe"},
|
|
{"name": "gpu-turnstile.exe.sha256", "browser_download_url": f.srv.URL + "/dl/sha"},
|
|
}
|
|
if !f.noSig {
|
|
assets = append(assets, map[string]string{"name": "gpu-turnstile.exe.sig", "browser_download_url": f.srv.URL + "/dl/sig"})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"tag_name": f.tag, "assets": assets})
|
|
})
|
|
mux.HandleFunc("/dl/exe", func(w http.ResponseWriter, r *http.Request) { w.Write(f.asset) })
|
|
mux.HandleFunc("/dl/sig", func(w http.ResponseWriter, r *http.Request) {
|
|
sig := sign()
|
|
if f.tamper {
|
|
sig[0] ^= 0xff
|
|
}
|
|
w.Write(sig)
|
|
})
|
|
mux.HandleFunc("/dl/sha", func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "%x gpu-turnstile.exe\n", sha256Bytes(f.asset))
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeGitea) updater(version string) *Updater {
|
|
return &Updater{Repo: f.srv.URL + "/o/r", Asset: "gpu-turnstile.exe", Version: version}
|
|
}
|
|
|
|
func fakeExe(t *testing.T) string {
|
|
t.Helper()
|
|
exe := filepath.Join(t.TempDir(), "gpu-turnstile.exe")
|
|
if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return exe
|
|
}
|
|
|
|
func withPublicKey(t *testing.T, pem string) {
|
|
t.Helper()
|
|
old := publicKeyPEM
|
|
publicKeyPEM = pem
|
|
t.Cleanup(func() { publicKeyPEM = old })
|
|
}
|
|
|
|
func TestCheckStagesUpdate(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
|
|
staged, err := f.updater("v0.1.2").Check(context.Background(), exe)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !staged {
|
|
t.Fatal("expected staged update")
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "new-binary" {
|
|
t.Fatalf("exe content = %q", content)
|
|
}
|
|
old, _ := os.ReadFile(exe + ".old")
|
|
if string(old) != "old-binary" {
|
|
t.Fatalf(".old content = %q", old)
|
|
}
|
|
}
|
|
|
|
func TestCheckRejectsTamperedSignature(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
f.tamper = true
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
|
|
staged, err := f.updater("v0.1.2").Check(context.Background(), exe)
|
|
if err == nil {
|
|
t.Fatal("expected signature error")
|
|
}
|
|
if staged {
|
|
t.Fatal("must not stage on bad signature")
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "old-binary" {
|
|
t.Fatal("exe was modified despite bad signature")
|
|
}
|
|
}
|
|
|
|
func TestCheckSkipsOlderOrEqual(t *testing.T) {
|
|
for _, tag := range []string{"v0.1.2", "v0.1.1", "v0.0.9"} {
|
|
f := newFakeGitea(t, tag, []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
staged, err := f.updater("v0.1.2").Check(context.Background(), fakeExe(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if staged {
|
|
t.Fatalf("tag %s must not stage over v0.1.2", tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckSkipsWithoutPublicKey(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, "")
|
|
staged, err := f.updater("v0.1.2").Check(context.Background(), fakeExe(t))
|
|
if err != nil || staged {
|
|
t.Fatalf("staged=%v err=%v, want no action without key", staged, err)
|
|
}
|
|
}
|
|
|
|
func TestCheckSkipsDevBuild(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
staged, err := f.updater("dev").Check(context.Background(), fakeExe(t))
|
|
if err != nil || staged {
|
|
t.Fatalf("staged=%v err=%v, want no action for dev build", staged, err)
|
|
}
|
|
}
|
|
|
|
func TestNewerVersion(t *testing.T) {
|
|
cases := []struct {
|
|
cur, lat string
|
|
want bool
|
|
}{
|
|
{"v0.1.2", "v0.1.3", true},
|
|
{"0.1.2", "0.2.0", true},
|
|
{"v0.1.2", "v1.0.0", true},
|
|
{"v0.1.2", "v0.1.2", false},
|
|
{"v1.2.3", "v1.2.10", true},
|
|
{"v1.2.10", "v1.2.3", false},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := newerVersion(c.cur, c.lat)
|
|
if err != nil || got != c.want {
|
|
t.Errorf("newerVersion(%s, %s) = %v, %v; want %v", c.cur, c.lat, got, err, c.want)
|
|
}
|
|
}
|
|
if _, err := newerVersion("v0.1", "v0.1.2"); err == nil {
|
|
t.Error("expected error for malformed version")
|
|
}
|
|
}
|