diff --git a/Makefile b/Makefile index 75467a798..d1b5a06a5 100644 --- a/Makefile +++ b/Makefile @@ -313,6 +313,21 @@ _build_release_inner: build_native $(GO_RELEASE_BUILD_ARGS) \ -o $(BIN_DIR)/jetkvm_app cmd/main.go +# Package a signed app binary into an offline update archive. +# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, bin/jetkvm_app.sig, +# and bin/jetkvm_app.pub to already exist (produced by the signing step +# in release/test_production_release). +offline_archive_app: + @echo "Creating offline update archive for app..." + @for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub; do \ + if [ ! -f "$(BIN_DIR)/$$f" ]; then \ + echo "Error: $(BIN_DIR)/$$f not found. Run signing step first."; exit 1; \ + fi; \ + done + tar czf $(BIN_DIR)/jetkvm_app_offline_update.tar.gz \ + -C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub + @echo "✓ Created $(BIN_DIR)/jetkvm_app_offline_update.tar.gz" + release: git_check_dev check_r2 @if [ -z "$(SIGNING_KEY_FPR)" ]; then \ echo "Error: SIGNING_KEY_FPR is required for releases"; \ @@ -365,11 +380,13 @@ release: git_check_dev check_r2 rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256 rclone copyto bin/jetkvm_app.sig r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sig + $(MAKE) offline_archive_app + rclone copyto bin/jetkvm_app_offline_update.tar.gz r2://jetkvm-update/app/$(VERSION)/jetkvm_app_offline_update.tar.gz ./scripts/deploy_cloud_app.sh -v $(VERSION) --set-as-default --skip-confirmation @git tag release/$(VERSION) @git push origin release/$(VERSION) prev_prod=$$(gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName'); \ - gh release create release/$(VERSION) bin/jetkvm_app bin/jetkvm_app.sha256 bin/jetkvm_app.sig \ + gh release create release/$(VERSION) bin/jetkvm_app bin/jetkvm_app.sha256 bin/jetkvm_app.sig bin/jetkvm_app_offline_update.tar.gz \ --title "$(VERSION)" \ --generate-notes \ --notes-start-tag "$$prev_prod" \ diff --git a/internal/ota/gpg.go b/internal/ota/gpg.go index ebc47025f..f336e25f7 100644 --- a/internal/ota/gpg.go +++ b/internal/ota/gpg.go @@ -195,7 +195,7 @@ func (g *GPGVerifier) parseAndValidateKeyring(key []byte) (openpgp.EntityList, e fp := normalizeFingerprint(hex.EncodeToString(entity.PrimaryKey.Fingerprint[:])) if fp == expected { - return keyring, nil + return openpgp.EntityList{entity}, nil } } @@ -256,6 +256,31 @@ func (g *GPGVerifier) VerifySignatureFromFile(ctx context.Context, signature []b return nil } +// VerifySignatureFromFileWithKey verifies a detached GPG signature against a +// file using a caller-supplied armored public key rather than fetching from +// keyservers. The key is validated against the pinned root fingerprint before +// use. This is the verification path for offline updates where keyservers are +// unreachable. +func (g *GPGVerifier) VerifySignatureFromFileWithKey(signature []byte, filePath string, armoredKey []byte) error { + keyring, err := g.parseAndValidateKeyring(armoredKey) + if err != nil { + return fmt.Errorf("bundled public key rejected: %w", err) + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file for verification: %w", err) + } + defer file.Close() + + if _, err := openpgp.CheckDetachedSignature(keyring, file, bytes.NewReader(signature), nil); err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + + g.logger.Info().Str("file", filePath).Msg("offline signature verification successful") + return nil +} + // ClearCache clears the cached public key (useful for testing) func (g *GPGVerifier) ClearCache() { g.mu.Lock() diff --git a/internal/ota/gpg_test.go b/internal/ota/gpg_test.go index 5303e3487..902f1ce5a 100644 --- a/internal/ota/gpg_test.go +++ b/internal/ota/gpg_test.go @@ -302,6 +302,43 @@ func TestFetchPublicKey_CachedKeyIsValid(t *testing.T) { assert.Equal(t, int32(1), callCount.Load(), "VerifySignature should use cached key") } +func TestParseAndValidateKeyring_FiltersRogueKeys(t *testing.T) { + // Generate the trusted key and a rogue key + trustedEntity, err := openpgp.NewEntity("Trusted", "", "trusted@example.com", nil) + require.NoError(t, err) + rogueEntity, err := openpgp.NewEntity("Rogue", "", "rogue@example.com", nil) + require.NoError(t, err) + + // Armor both keys into a single keyring (as a malicious keyserver would) + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + require.NoError(t, trustedEntity.Serialize(w)) + require.NoError(t, rogueEntity.Serialize(w)) + require.NoError(t, w.Close()) + + trustedFP := strings.ToUpper(hex.EncodeToString(trustedEntity.PrimaryKey.Fingerprint[:])) + + v := newTestGPGVerifier() + v.rootKeyFP = trustedFP + + keyring, err := v.parseAndValidateKeyring(buf.Bytes()) + require.NoError(t, err) + require.Len(t, keyring, 1, "keyring must contain only the trusted key, not the rogue key") + + returnedFP := strings.ToUpper(hex.EncodeToString(keyring[0].PrimaryKey.Fingerprint[:])) + assert.Equal(t, trustedFP, returnedFP) + + // Sign data with the rogue key — verification must fail + testData := []byte("payload") + var sigBuf bytes.Buffer + err = openpgp.DetachSign(&sigBuf, rogueEntity, bytes.NewReader(testData), nil) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring, bytes.NewReader(testData), bytes.NewReader(sigBuf.Bytes()), nil) + assert.Error(t, err, "signature from rogue key must not verify against filtered keyring") +} + func TestFetchPublicKey_RejectsFingerprintMismatch(t *testing.T) { expectedKey := generateTestArmoredKey(t) servedKey := generateTestArmoredKey(t) diff --git a/internal/ota/offline.go b/internal/ota/offline.go new file mode 100644 index 000000000..b81b85738 --- /dev/null +++ b/internal/ota/offline.go @@ -0,0 +1,213 @@ +package ota + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog" +) + +// OfflineBundle represents a validated offline update archive that has been +// extracted and is ready for verification. +type OfflineBundle struct { + BinaryPath string // absolute path to the extracted binary + ExpectedHash string // SHA256 hex digest read from the .sha256 file + Signature []byte // raw GPG signature bytes + PublicKeyData []byte // armored GPG public key bytes + Component string // "app" or "system" +} + +// OfflineVerifyResult captures the outcome of offline bundle verification. +type OfflineVerifyResult struct { + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` +} + +// expectedBinaryNames maps component names to the binary filename expected +// inside the offline update archive. +var expectedBinaryNames = map[string]string{ + "app": "jetkvm_app", + "system": "update_system.tar", +} + +// ExtractOfflineArchive reads a gzipped tar archive from r and extracts it +// into destDir. It validates the archive structure: exactly one binary +// matching the component, one .sha256 hash file, and optionally one .sig +// file. Path traversal attempts are rejected. +func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zerolog.Logger) (*OfflineBundle, error) { + binaryName, ok := expectedBinaryNames[component] + if !ok { + return nil, fmt.Errorf("unknown component: %s", component) + } + + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + + bundle := &OfflineBundle{Component: component} + fileCount := 0 + const maxFiles = 4 // binary + .sha256 + .sig + .pub + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("error reading tar: %w", err) + } + + name := filepath.Clean(header.Name) + + // strip leading directory component if the archive was created with one + name = filepath.Base(name) + + if strings.Contains(name, "..") || filepath.IsAbs(name) { + return nil, fmt.Errorf("path traversal detected in archive: %s", header.Name) + } + + // skip directories + if header.Typeflag == tar.TypeDir { + continue + } + + fileCount++ + if fileCount > maxFiles { + return nil, fmt.Errorf("archive contains more than %d files", maxFiles) + } + + destPath := filepath.Join(destDir, name) + + switch { + case name == binaryName: + if err := extractFileFromTar(tr, destPath, header.Mode); err != nil { + return nil, fmt.Errorf("error extracting binary: %w", err) + } + bundle.BinaryPath = destPath + l.Debug().Str("path", destPath).Msg("extracted binary") + + case name == binaryName+".sha256": + hashBytes, err := io.ReadAll(io.LimitReader(tr, 256)) + if err != nil { + return nil, fmt.Errorf("error reading hash file: %w", err) + } + // hash file format: " " or just "" + hashStr := strings.TrimSpace(string(hashBytes)) + if idx := strings.IndexByte(hashStr, ' '); idx > 0 { + hashStr = hashStr[:idx] + } + bundle.ExpectedHash = strings.ToLower(hashStr) + l.Debug().Str("hash", bundle.ExpectedHash).Msg("read expected hash") + + case name == binaryName+".sig": + sig, err := io.ReadAll(io.LimitReader(tr, 8192)) + if err != nil { + return nil, fmt.Errorf("error reading signature file: %w", err) + } + bundle.Signature = sig + l.Debug().Int("bytes", len(sig)).Msg("read signature") + + case name == binaryName+".pub": + pub, err := io.ReadAll(io.LimitReader(tr, 65536)) + if err != nil { + return nil, fmt.Errorf("error reading public key file: %w", err) + } + bundle.PublicKeyData = pub + l.Debug().Int("bytes", len(pub)).Msg("read public key") + + default: + return nil, fmt.Errorf("unexpected file in archive: %s", name) + } + } + + if bundle.BinaryPath == "" { + return nil, fmt.Errorf("archive missing required binary: %s", binaryName) + } + if bundle.ExpectedHash == "" { + return nil, fmt.Errorf("archive missing required hash file: %s.sha256", binaryName) + } + if len(bundle.Signature) == 0 { + return nil, fmt.Errorf("archive missing required signature file: %s.sig", binaryName) + } + if len(bundle.PublicKeyData) == 0 { + return nil, fmt.Errorf("archive missing required public key file: %s.pub", binaryName) + } + + return bundle, nil +} + +// extractFileFromTar writes a tar entry to the given destination path. +func extractFileFromTar(tr *tar.Reader, destPath string, mode int64) error { + f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode)|0644) + if err != nil { + return fmt.Errorf("error creating file %s: %w", destPath, err) + } + defer f.Close() + + if _, err := io.Copy(f, tr); err != nil { + return fmt.Errorf("error writing file %s: %w", destPath, err) + } + return nil +} + +// VerifyOfflineBundle checks the SHA256 hash and GPG signature of an +// extracted offline bundle. The bundled public key is validated against the +// pinned root fingerprint before use — no keyserver access is required. +// Hash mismatches and signature failures are always fatal. +func VerifyOfflineBundle(bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) { + result := &OfflineVerifyResult{} + + // SHA256 verification + hash, err := hashFile(bundle.BinaryPath) + if err != nil { + return nil, fmt.Errorf("error hashing file: %w", err) + } + + if hash != bundle.ExpectedHash { + return nil, fmt.Errorf("hash mismatch: got %s, expected %s", hash, bundle.ExpectedHash) + } + result.HashOK = true + l.Info().Str("hash", hash).Msg("SHA256 hash verified") + + // GPG signature verification using the bundled public key + if len(bundle.Signature) == 0 { + return nil, fmt.Errorf("signature is required for offline updates") + } + if len(bundle.PublicKeyData) == 0 { + return nil, fmt.Errorf("public key is required for offline updates") + } + + if err := gpgVerifier.VerifySignatureFromFileWithKey(bundle.Signature, bundle.BinaryPath, bundle.PublicKeyData); err != nil { + return nil, fmt.Errorf("GPG signature verification failed: %w", err) + } + + result.SignatureOK = true + l.Info().Msg("GPG signature verified") + return result, nil +} + +// hashFile computes the SHA256 hex digest of the file at path. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/ota/offline_test.go b/internal/ota/offline_test.go new file mode 100644 index 000000000..960e29658 --- /dev/null +++ b/internal/ota/offline_test.go @@ -0,0 +1,580 @@ +package ota + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildArchive creates a tar.gz in memory from a map of filename→content. +func buildArchive(t *testing.T, files map[string][]byte) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range files { + err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0644, + }) + require.NoError(t, err) + _, err = tw.Write(content) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return &buf +} + +func sha256hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func testLogger() *zerolog.Logger { + l := zerolog.New(io.Discard) + return &l +} + +// armorPublicKey returns the armored public key bytes for the given entity. +func armorPublicKey(t *testing.T, entity *openpgp.Entity) []byte { + t.Helper() + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + require.NoError(t, entity.Serialize(w)) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +// buildAppArchive creates a complete 4-file offline update archive for +// the "app" component using the given binary, signature, and public key. +func buildAppArchive(t *testing.T, binary, sig, pub []byte) *bytes.Buffer { + t.Helper() + return buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(sha256hex(binary) + " jetkvm_app\n"), + "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, + }) +} + +func TestExtractOfflineArchive_ValidApp(t *testing.T) { + binary := []byte("fake-app-binary") + hash := sha256hex(binary) + sig := []byte("fake-signature-bytes") + pub := []byte("fake-public-key") + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), + "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, + }) + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + require.NoError(t, err) + + assert.Equal(t, "app", bundle.Component) + assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, sig, bundle.Signature) + assert.Equal(t, pub, bundle.PublicKeyData) + assert.Equal(t, filepath.Join(destDir, "jetkvm_app"), bundle.BinaryPath) + + // binary should be on disk + content, err := os.ReadFile(bundle.BinaryPath) + require.NoError(t, err) + assert.Equal(t, binary, content) +} + +func TestExtractOfflineArchive_ValidSystem(t *testing.T) { + binary := []byte("fake-system-tar") + hash := sha256hex(binary) + sig := []byte("fake-sig") + pub := []byte("fake-pub") + + archive := buildArchive(t, map[string][]byte{ + "update_system.tar": binary, + "update_system.tar.sha256": []byte(hash), + "update_system.tar.sig": sig, + "update_system.tar.pub": pub, + }) + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, destDir, "system", testLogger()) + require.NoError(t, err) + + assert.Equal(t, "system", bundle.Component) + assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, pub, bundle.PublicKeyData) +} + +func TestExtractOfflineArchive_MissingSig(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.pub": []byte("pub"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required signature file") +} + +func TestExtractOfflineArchive_MissingPub(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": []byte("sig"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required public key file") +} + +func TestExtractOfflineArchive_MissingHash(t *testing.T) { + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": []byte("binary"), + "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required hash file") +} + +func TestExtractOfflineArchive_MissingBinary(t *testing.T) { + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app.sha256": []byte("abc123"), + "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required binary") +} + +func TestExtractOfflineArchive_UnexpectedFile(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), + "malicious.sh": []byte("#!/bin/bash\nrm -rf /"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.Error(t, err) // either "unexpected file" or "more than 4 files" +} + +func TestExtractOfflineArchive_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + _ = tw.WriteHeader(&tar.Header{ + Name: "../../etc/passwd", + Size: 5, + Mode: 0644, + }) + _, _ = tw.Write([]byte("pwned")) + _ = tw.Close() + _ = gw.Close() + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(&buf, destDir, "app", testLogger()) + assert.Error(t, err) +} + +func TestExtractOfflineArchive_UnknownComponent(t *testing.T) { + archive := buildArchive(t, map[string][]byte{}) + _, err := ExtractOfflineArchive(archive, t.TempDir(), "unknown", testLogger()) + assert.ErrorContains(t, err, "unknown component") +} + +func TestExtractOfflineArchive_CorruptGzip(t *testing.T) { + _, err := ExtractOfflineArchive(bytes.NewReader([]byte("not-gzip")), t.TempDir(), "app", testLogger()) + assert.ErrorContains(t, err, "gzip") +} + +func TestExtractOfflineArchive_NestedDirectory(t *testing.T) { + binary := []byte("app-binary") + hash := sha256hex(binary) + sig := []byte("sig-bytes") + pub := []byte("pub-bytes") + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range map[string][]byte{ + "jetkvm_app_offline_update/jetkvm_app": binary, + "jetkvm_app_offline_update/jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), + "jetkvm_app_offline_update/jetkvm_app.sig": sig, + "jetkvm_app_offline_update/jetkvm_app.pub": pub, + } { + _ = tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0644}) + _, _ = tw.Write(content) + } + _ = tw.Close() + _ = gw.Close() + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(&buf, destDir, "app", testLogger()) + require.NoError(t, err) + assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, sig, bundle.Signature) + assert.Equal(t, pub, bundle.PublicKeyData) +} + +func TestHashFile(t *testing.T) { + content := []byte("hello world") + expected := sha256hex(content) + + path := filepath.Join(t.TempDir(), "test") + require.NoError(t, os.WriteFile(path, content, 0644)) + + got, err := hashFile(path) + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +// --- VerifyOfflineBundle tests --- + +// newOfflineSigningFixture generates a GPG key pair and returns a GPGVerifier +// with the correct root fingerprint and the entity for signing. +// Unlike the keyserver-based fixture, this verifier doesn't need a mock HTTP +// client — offline verification uses VerifySignatureFromFileWithKey directly. +func newOfflineSigningFixture(t *testing.T) (*GPGVerifier, *openpgp.Entity) { + t.Helper() + + entity, err := openpgp.NewEntity("Offline Test", "", "offline@test.local", nil) + require.NoError(t, err) + + logger := zerolog.New(os.Stdout).Level(zerolog.WarnLevel) + v := &GPGVerifier{ + logger: &logger, + rootKeyFP: strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint[:])), + } + + return v, entity +} + +// signData produces a detached GPG signature over data using entity's private key. +func signData(t *testing.T, entity *openpgp.Entity, data []byte) []byte { + t.Helper() + var sigBuf bytes.Buffer + err := openpgp.DetachSign(&sigBuf, entity, bytes.NewReader(data), nil) + require.NoError(t, err) + return sigBuf.Bytes() +} + +// writeBundle writes a binary to disk and returns an OfflineBundle ready for verification. +func writeBundle(t *testing.T, binary, sig, pub []byte) *OfflineBundle { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "jetkvm_app") + require.NoError(t, os.WriteFile(path, binary, 0644)) + return &OfflineBundle{ + BinaryPath: path, + ExpectedHash: sha256hex(binary), + Signature: sig, + PublicKeyData: pub, + Component: "app", + } +} + +func TestVerifyOfflineBundle_ValidSignature(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("valid-app-binary-content") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig, pub) + + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK, "hash should pass") + assert.True(t, result.SignatureOK, "signature should pass") +} + +func TestVerifyOfflineBundle_HashMismatch(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("real-binary") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig, pub) + + bundle.ExpectedHash = "0000000000000000000000000000000000000000000000000000000000000000" + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +func TestVerifyOfflineBundle_InvalidSignature(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("the-real-binary") + differentContent := []byte("tampered-binary") + + sig := signData(t, entity, differentContent) + bundle := writeBundle(t, binary, sig, pub) + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +func TestVerifyOfflineBundle_WrongKey(t *testing.T) { + gpgVerifier, _ := newOfflineSigningFixture(t) + + // Sign with a completely different key pair + otherEntity, err := openpgp.NewEntity("Attacker", "", "evil@attacker.com", nil) + require.NoError(t, err) + otherPub := armorPublicKey(t, otherEntity) + + binary := []byte("innocent-looking-binary") + sig := signData(t, otherEntity, binary) + // Bundle includes the attacker's pub key, which won't match the pinned fingerprint + bundle := writeBundle(t, binary, sig, otherPub) + + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "bundled public key rejected") +} + +func TestVerifyOfflineBundle_EmptySignature(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("unsigned-binary") + bundle := writeBundle(t, binary, nil, pub) + bundle.Signature = nil + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "signature is required") +} + +func TestVerifyOfflineBundle_EmptyPublicKey(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + + binary := []byte("binary-without-key") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig, nil) + bundle.PublicKeyData = nil + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "public key is required") +} + +func TestVerifyOfflineBundle_TruncatedSignature(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("binary-with-truncated-sig") + fullSig := signData(t, entity, binary) + + truncatedSig := fullSig[:len(fullSig)/2] + bundle := writeBundle(t, binary, truncatedSig, pub) + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +func TestVerifyOfflineBundle_CorruptedBinary(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + originalBinary := []byte("original-binary-content") + sig := signData(t, entity, originalBinary) + + bundle := writeBundle(t, originalBinary, sig, pub) + require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("corrupted-binary"), 0644)) + + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// --- ComponentUpdatePath tests --- + +func TestComponentUpdatePath_App(t *testing.T) { + path, err := ComponentUpdatePath("app") + require.NoError(t, err) + assert.Equal(t, appUpdatePath, path) +} + +func TestComponentUpdatePath_System(t *testing.T) { + path, err := ComponentUpdatePath("system") + require.NoError(t, err) + assert.Equal(t, systemUpdatePath, path) +} + +func TestComponentUpdatePath_Unknown(t *testing.T) { + _, err := ComponentUpdatePath("unknown") + assert.ErrorContains(t, err, "unknown component") +} + +// --- End-to-end pipeline tests --- + +// TestEndToEnd_ExtractAndVerify_ValidArchive exercises the full pipeline: +// build a tar.gz with a real GPG signature → extract → verify. +func TestEndToEnd_ExtractAndVerify_ValidArchive(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("end-to-end-test-binary-content-here") + sig := signData(t, entity, binary) + + archive := buildAppArchive(t, binary, sig, pub) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK) + assert.True(t, result.SignatureOK) +} + +// TestEndToEnd_ExtractAndVerify_TamperedBinary builds a valid archive then +// overwrites the extracted binary before verification — simulating +// file-level tampering after extraction. +func TestEndToEnd_ExtractAndVerify_TamperedBinary(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("legitimate-binary") + sig := signData(t, entity, binary) + + archive := buildAppArchive(t, binary, sig, pub) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("tampered!"), 0644)) + + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// TestEndToEnd_ExtractAndVerify_WrongSignature builds an archive where +// the signature was produced by a different key than the verifier expects. +func TestEndToEnd_ExtractAndVerify_WrongSignature(t *testing.T) { + gpgVerifier, _ := newOfflineSigningFixture(t) // verifier expects key A + + attackerEntity, err := openpgp.NewEntity("Attacker", "", "evil@example.com", nil) + require.NoError(t, err) + attackerPub := armorPublicKey(t, attackerEntity) + + binary := []byte("innocuous-binary") + sig := signData(t, attackerEntity, binary) // signed with key B + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(sha256hex(binary)), + "jetkvm_app.sig": sig, + "jetkvm_app.pub": attackerPub, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "bundled public key rejected") +} + +// TestEndToEnd_ExtractAndVerify_HashMismatchInArchive builds an archive +// where the .sha256 file contains the wrong hash for the binary. +func TestEndToEnd_ExtractAndVerify_HashMismatchInArchive(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("real-binary-content") + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(wrongHash), + "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + assert.Equal(t, wrongHash, bundle.ExpectedHash) + + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// TestEndToEnd_SystemArchive verifies the full pipeline works for system +// component archives with the different expected file names. +func TestEndToEnd_SystemArchive(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) + + binary := []byte("system-image-tar-content") + hash := sha256hex(binary) + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "update_system.tar": binary, + "update_system.tar.sha256": []byte(hash), + "update_system.tar.sig": sig, + "update_system.tar.pub": pub, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "system", testLogger()) + require.NoError(t, err) + assert.Equal(t, "system", bundle.Component) + + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK) + assert.True(t, result.SignatureOK) +} diff --git a/internal/ota/state.go b/internal/ota/state.go index e2fffbdd6..5ebb9d776 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -1,6 +1,8 @@ package ota import ( + "context" + "fmt" "sync" "time" @@ -178,11 +180,89 @@ func (s *State) ToUpdateStatus() *UpdateStatus { return toUpdateStatus(&appUpdate, &systemUpdate, s.error) } -// IsUpdatePending returns true if an update is pending +// IsUpdatePending returns true if an update is currently in progress. func (s *State) IsUpdatePending() bool { return s.updating } +// ApplyOfflineUpdate applies a pre-verified, pre-staged offline update for +// the given component. For app updates, this simply triggers a reboot (the +// boot sequence picks up jetkvm_app.update). For system updates, it runs +// rk_ota then reboots. The caller is responsible for ensuring the file has +// been verified and staged at the correct path before calling this. +func (s *State) ApplyOfflineUpdate(ctx context.Context, component string) error { + locked := s.mu.TryLock() + if !locked { + return fmt.Errorf("update already in progress") + } + defer s.mu.Unlock() + + s.updating = true + s.metadataFetchedAt = time.Now() + s.triggerStateUpdate() + + componentStatus := componentUpdateStatus{ + pending: true, + downloadProgress: 1, + downloadFinishedAt: time.Now(), + verificationProgress: 1, + verifiedAt: time.Now(), + } + + s.triggerComponentUpdateState(component, &componentStatus) + + l := s.l.With().Str("component", component).Logger() + + if component == "system" { + if err := s.applySystemImage(&componentStatus); err != nil { + return s.componentUpdateError("Error applying offline system update", err, &l) + } + } else { + // App: the verified binary is already at appUpdatePath; just mark complete. + componentStatus.updateProgress = 1 + componentStatus.updatedAt = time.Now() + s.triggerComponentUpdateState(component, &componentStatus) + } + + s.rebootNeeded = true + + // Disable auto-update: the user explicitly chose an offline update version. + if _, err := s.setAutoUpdate(false); err != nil { + l.Warn().Err(err).Msg("failed to disable auto-update after offline update") + } + + l.Info().Msg("rebooting after offline update") + + postRebootAction := &PostRebootAction{ + HealthCheck: "/device/status", + RedirectTo: "/settings/general/update", + } + + if err := s.reboot(true, postRebootAction, 5*time.Second); err != nil { + return s.componentUpdateError("Error requesting reboot", err, &l) + } + + return nil +} + +// GPGVerifier returns the GPG verifier instance used by this state. +func (s *State) GPGVerifier() *GPGVerifier { + return s.gpgVerifier +} + +// ComponentUpdatePath returns the filesystem path where verified update +// files should be staged for the given component. +func ComponentUpdatePath(component string) (string, error) { + switch component { + case "app": + return appUpdatePath, nil + case "system": + return systemUpdatePath, nil + default: + return "", fmt.Errorf("unknown component: %s", component) + } +} + // Options represents the options for the OTA state type Options struct { Logger *zerolog.Logger diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 162aa14a8..156c8c767 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -49,6 +49,23 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS l.Info().Msg("System update downloaded") + if err := s.applySystemImage(systemUpdate); err != nil { + return err + } + + s.rebootNeeded = true + systemUpdate.updateProgress = 1 + systemUpdate.updatedAt = verifyFinished + s.triggerComponentUpdateState("system", systemUpdate) + + return nil +} + +// applySystemImage runs rk_ota to flash a verified system tar that is +// already staged at systemUpdatePath. +func (s *State) applySystemImage(systemUpdate *componentUpdateStatus) error { + l := s.l.With().Str("path", systemUpdatePath).Logger() + l.Info().Msg("Starting rk_ota command") cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all") @@ -82,21 +99,20 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS } }() - err = cmd.Wait() + if err := cmd.Wait(); err != nil { + cancel() + rkLogger := s.l.With(). + Str("output", b.String()). + Int("exitCode", cmd.ProcessState.ExitCode()).Logger() + return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) + } cancel() + rkLogger := s.l.With(). Str("output", b.String()). Int("exitCode", cmd.ProcessState.ExitCode()).Logger() - if err != nil { - return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) - } rkLogger.Info().Msg("rk_ota success") - s.rebootNeeded = true - systemUpdate.updateProgress = 1 - systemUpdate.updatedAt = verifyFinished - s.triggerComponentUpdateState("system", systemUpdate) - return nil } diff --git a/ota_offline.go b/ota_offline.go new file mode 100644 index 000000000..0c3d9ba07 --- /dev/null +++ b/ota_offline.go @@ -0,0 +1,201 @@ +package kvm + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/jetkvm/kvm/internal/ota" +) + +const ( + // maxOfflineUploadSize limits offline update archives to 200MB. + maxOfflineUploadSize = 200 << 20 +) + +// offlineUpdateUploadResponse is returned by POST /ota/upload. +type offlineUpdateUploadResponse struct { + Verified bool `json:"verified"` + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` + Error string `json:"error,omitempty"` +} + +// handleOfflineUpdateUpload handles POST /ota/upload. +// Accepts a multipart form with fields: +// - component: "app" or "system" +// - file: the .tar.gz offline update archive +// +// Extracts, validates structure, verifies hash + GPG signature, and stages +// the verified binary at the standard OTA path. +func handleOfflineUpdateUpload(c *gin.Context) { + if otaState.IsUpdatePending() { + c.JSON(http.StatusConflict, offlineUpdateUploadResponse{ + Error: "an update is already in progress", + }) + return + } + + component := c.PostForm("component") + if component != "app" && component != "system" { + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: "component must be 'app' or 'system'", + }) + return + } + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: "missing or invalid file upload", + }) + return + } + + if file.Size > maxOfflineUploadSize { + c.JSON(http.StatusRequestEntityTooLarge, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("file exceeds maximum size of %d MB", maxOfflineUploadSize>>20), + }) + return + } + + f, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to read uploaded file", + }) + return + } + defer f.Close() + + // Extract to a temp directory + extractDir, err := os.MkdirTemp("", "jetkvm-offline-update-*") + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to create temporary directory", + }) + return + } + defer os.RemoveAll(extractDir) + + l := otaLogger.With().Str("component", component).Logger() + + bundle, err := ota.ExtractOfflineArchive(f, extractDir, component, &l) + if err != nil { + l.Warn().Err(err).Msg("offline archive extraction failed") + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("invalid archive: %v", err), + }) + return + } + + result, err := ota.VerifyOfflineBundle(bundle, otaState.GPGVerifier(), &l) + if err != nil { + l.Warn().Err(err).Msg("offline bundle verification failed") + c.JSON(http.StatusUnprocessableEntity, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("verification failed: %v", err), + }) + return + } + + // Stage the verified binary at the standard OTA path + destPath, err := ota.ComponentUpdatePath(component) + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("internal error: %v", err), + }) + return + } + + if err := os.Rename(bundle.BinaryPath, destPath); err != nil { + // Rename may fail across filesystems; fall back to copy + if err := copyFile(bundle.BinaryPath, destPath); err != nil { + l.Error().Err(err).Msg("failed to stage verified binary") + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to stage verified update file", + }) + return + } + } + + if err := os.Chmod(destPath, 0755); err != nil { + l.Warn().Err(err).Msg("failed to set permissions on staged file") + } + + l.Info(). + Bool("hashOK", result.HashOK). + Bool("signatureOK", result.SignatureOK). + Msg("offline update uploaded and verified") + + c.JSON(http.StatusOK, offlineUpdateUploadResponse{ + Verified: result.HashOK && result.SignatureOK, + HashOK: result.HashOK, + SignatureOK: result.SignatureOK, + }) +} + +// offlineUpdateApplyRequest is the body for POST /ota/apply. +type offlineUpdateApplyRequest struct { + Component string `json:"component" binding:"required"` +} + +// handleOfflineUpdateApply handles POST /ota/apply. +// Applies a previously uploaded and verified offline update. +func handleOfflineUpdateApply(c *gin.Context) { + var req offlineUpdateApplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Component != "app" && req.Component != "system" { + c.JSON(http.StatusBadRequest, gin.H{"error": "component must be 'app' or 'system'"}) + return + } + + // Verify the staged file exists + destPath, err := ota.ComponentUpdatePath(req.Component) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("internal error: %v", err)}) + return + } + + if _, err := os.Stat(destPath); os.IsNotExist(err) { + c.JSON(http.StatusBadRequest, gin.H{"error": "no staged update found; upload first"}) + return + } + + l := otaLogger.With().Str("component", req.Component).Logger() + l.Info().Msg("applying offline update") + + // Apply asynchronously — the device will reboot + go func() { + if err := otaState.ApplyOfflineUpdate(context.Background(), req.Component); err != nil { + l.Error().Err(err).Msg("offline update apply failed") + } + }() + + c.JSON(http.StatusOK, gin.H{"message": "update is being applied; device will reboot"}) +} + +// copyFile copies src to dst, used when os.Rename fails across filesystems. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := out.ReadFrom(in); err != nil { + return err + } + return out.Sync() +} diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 4f566e5e8..fc9af1f3d 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -973,5 +973,19 @@ "wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "welcome_to_jetkvm": "Welcome to JetKVM", - "welcome_to_jetkvm_description": "Control any computer remotely" + "welcome_to_jetkvm_description": "Control any computer remotely", + "offline_update_title": "Offline Update", + "offline_update_description": "Upload an offline update archive (.tar.gz) downloaded from JetKVM releases.", + "offline_update_app_label": "App Update", + "offline_update_system_label": "System Update", + "offline_update_select_file": "Select File", + "offline_update_uploading": "Uploading…", + "offline_update_verifying": "Verifying…", + "offline_update_verified": "Verified", + "offline_update_applying": "Applying update…", + "offline_update_apply": "Apply Update", + "offline_update_hash_ok": "Hash verified", + "offline_update_signature_ok": "Signature verified", + "offline_update_error": "Upload failed: {error}", + "offline_update_invalid_file": "Please select a .tar.gz archive" } diff --git a/ui/src/components/OfflineUpdateCard.tsx b/ui/src/components/OfflineUpdateCard.tsx new file mode 100644 index 000000000..0b3665293 --- /dev/null +++ b/ui/src/components/OfflineUpdateCard.tsx @@ -0,0 +1,231 @@ +import { useCallback, useRef, useState } from "react"; +import { LuUpload, LuCheck } from "react-icons/lu"; + +import { Button } from "@components/Button"; +import { SettingsItem } from "@components/SettingsItem"; +import { DEVICE_API } from "@/ui.config"; +import { m } from "@localizations/messages.js"; + +interface UploadResult { + verified: boolean; + hashOK: boolean; + signatureOK: boolean; + error?: string; +} + +type UploadState = "idle" | "uploading" | "verifying" | "verified" | "applying" | "error"; + +interface ComponentUploadState { + state: UploadState; + progress: number; + result: UploadResult | null; + error: string | null; +} + +const initialState: ComponentUploadState = { + state: "idle", + progress: 0, + result: null, + error: null, +}; + +function ComponentUpload({ component, label }: { component: string; label: string }) { + const [upload, setUpload] = useState(initialState); + const fileInputRef = useRef(null); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) { + setUpload({ + ...initialState, + state: "error", + error: m.offline_update_invalid_file(), + }); + return; + } + + const formData = new FormData(); + formData.append("component", component); + formData.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${DEVICE_API}/ota/upload`, true); + + setUpload({ ...initialState, state: "uploading" }); + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + const pct = Math.round((event.loaded / event.total) * 100); + setUpload(prev => ({ ...prev, progress: pct })); + if (pct >= 100) { + setUpload(prev => ({ ...prev, state: "verifying" })); + } + } + }; + + xhr.onload = () => { + try { + const result: UploadResult = JSON.parse(xhr.responseText); + if (xhr.status === 200 && result.verified) { + setUpload({ + state: "verified", + progress: 100, + result, + error: null, + }); + } else { + setUpload({ + state: "error", + progress: 0, + result: null, + error: result.error || `HTTP ${xhr.status}`, + }); + } + } catch { + setUpload({ + state: "error", + progress: 0, + result: null, + error: xhr.statusText || "Unknown error", + }); + } + }; + + xhr.onerror = () => { + setUpload({ + state: "error", + progress: 0, + result: null, + error: "Network error", + }); + }; + + xhr.send(formData); + + // Reset input so re-selecting the same file works + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [component], + ); + + const handleApply = useCallback( + () => { + setUpload(prev => ({ ...prev, state: "applying" })); + + fetch(`${DEVICE_API}/ota/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ component }), + }).catch(() => { + // Expected: the device reboots, dropping the connection + }); + }, + [component], + ); + + const reset = useCallback(() => { + setUpload(initialState); + }, []); + + return ( +
+

{label}

+ + {upload.state === "idle" && ( +
+
+ )} + + {(upload.state === "uploading" || upload.state === "verifying") && ( +
+
+ + {upload.state === "uploading" + ? m.offline_update_uploading() + : m.offline_update_verifying()} + + + {upload.progress}% + +
+
+
+
+
+ )} + + {upload.state === "verified" && upload.result && ( +
+
+ + {m.offline_update_hash_ok()} +
+
+ + {m.offline_update_signature_ok()} +
+
+
+
+ )} + + {upload.state === "applying" && ( +

+ {m.offline_update_applying()} +

+ )} + + {upload.state === "error" && upload.error && ( +
+

+ {m.offline_update_error({ error: upload.error })} +

+
+ )} +
+ ); +} + +export default function OfflineUpdateCard() { + return ( +
+ +
+ +
+ +
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index 78d790e67..a60b83c2e 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -5,6 +5,7 @@ import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { useDeviceStore } from "@hooks/stores"; import { Button } from "@components/Button"; import Checkbox from "@components/Checkbox"; +import OfflineUpdateCard from "@components/OfflineUpdateCard"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; @@ -136,6 +137,7 @@ export default function SettingsGeneralRoute() { />
+