mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-29 01:21:20 +02:00
feat(ota): add OTA signature verification and public key handling
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
30
Makefile
30
Makefile
@@ -2,12 +2,19 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
|||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.1.2-dev
|
VERSION_DEV ?= 0.1.3-dev
|
||||||
VERSION ?= 0.1.2
|
VERSION ?= 0.1.3
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := kvm
|
KVM_PKG_NAME := kvm
|
||||||
|
|
||||||
|
# OTA signing key path (Ed25519 private key for auto-signing at build time)
|
||||||
|
OTA_SIGNING_KEY ?=
|
||||||
|
|
||||||
|
# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars)
|
||||||
|
# Default empty = signature verification disabled (backward compatible)
|
||||||
|
OTA_PUBLIC_KEY ?=
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo
|
GO_BUILD_ARGS := -tags netgo
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
GO_LDFLAGS := \
|
GO_LDFLAGS := \
|
||||||
@@ -15,7 +22,8 @@ GO_LDFLAGS := \
|
|||||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||||
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \
|
||||||
|
-X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY)
|
||||||
|
|
||||||
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
|
||||||
BIN_DIR := $(shell pwd)/bin
|
BIN_DIR := $(shell pwd)/bin
|
||||||
@@ -28,6 +36,12 @@ build_dev:
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o $(BIN_DIR)/kvm_app cmd/main.go
|
-o $(BIN_DIR)/kvm_app cmd/main.go
|
||||||
|
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||||
|
echo "Signing $(BIN_DIR)/kvm_app..."; \
|
||||||
|
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \
|
||||||
|
else \
|
||||||
|
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||||
|
fi
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device
|
||||||
@@ -38,3 +52,13 @@ build_release: frontend
|
|||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o bin/kvm_app cmd/main.go
|
-o bin/kvm_app cmd/main.go
|
||||||
|
@if [ -n "$(OTA_SIGNING_KEY)" ]; then \
|
||||||
|
echo "Signing bin/kvm_app..."; \
|
||||||
|
go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \
|
||||||
|
else \
|
||||||
|
echo "OTA_SIGNING_KEY not set, skipping signing."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
sign:
|
||||||
|
@echo "Signing firmware files..."
|
||||||
|
go run cmd/main.go cli signer sign --key $(KEY) $(FILES)
|
||||||
|
|||||||
34
cli.go
34
cli.go
@@ -410,20 +410,23 @@ var signerKeygenCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signerSignCmd = &cobra.Command{
|
var signerSignCmd = &cobra.Command{
|
||||||
Use: "sign --key <private-key-path> <firmware-file>",
|
Use: "sign --key <private-key> <firmware-file>",
|
||||||
Short: "Sign a firmware file",
|
Short: "Sign a firmware file",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
keyPath, _ := cmd.Flags().GetString("key")
|
keyArg, _ := cmd.Flags().GetString("key")
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
|
||||||
if keyPath == "" {
|
if keyArg == "" {
|
||||||
return fmt.Errorf("--key is required")
|
return fmt.Errorf("--key is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKey, err := os.ReadFile(keyPath)
|
privateKey, err := os.ReadFile(keyArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading private key: %w", err)
|
privateKey, err = hex.DecodeString(keyArg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid private key: not a valid file path or hex string")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(privateKey) != ed25519.PrivateKeySize {
|
if len(privateKey) != ed25519.PrivateKeySize {
|
||||||
@@ -450,9 +453,9 @@ var signerSignCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signerVerifyCmd = &cobra.Command{
|
var signerVerifyCmd = &cobra.Command{
|
||||||
Use: "verify --pubkey <pubkey-path-or-hex> <firmware-file> [<sig-file>]",
|
Use: "verify [--pubkey <pubkey-path-or-hex>] <firmware-file> [<sig-file>]",
|
||||||
Short: "Verify firmware signature",
|
Short: "Verify firmware signature",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.RangeArgs(1, 2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
|
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
@@ -461,16 +464,23 @@ var signerVerifyCmd = &cobra.Command{
|
|||||||
sigPath = args[1]
|
sigPath = args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if pubKeyArg == "" {
|
|
||||||
return fmt.Errorf("--pubkey is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if sigPath == "" {
|
if sigPath == "" {
|
||||||
sigPath = filePath + ".sig"
|
sigPath = filePath + ".sig"
|
||||||
}
|
}
|
||||||
|
|
||||||
var publicKey ed25519.PublicKey
|
var publicKey ed25519.PublicKey
|
||||||
if _, err := os.Stat(pubKeyArg); err == nil {
|
|
||||||
|
if pubKeyArg == "" {
|
||||||
|
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
||||||
|
if keyStr == "" {
|
||||||
|
return fmt.Errorf("no --pubkey provided and no OTA public key embedded in binary")
|
||||||
|
}
|
||||||
|
keyBytes, err := hex.DecodeString(keyStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decoding embedded public key hex: %w", err)
|
||||||
|
}
|
||||||
|
publicKey = ed25519.PublicKey(keyBytes)
|
||||||
|
} else if _, err := os.Stat(pubKeyArg); err == nil {
|
||||||
keyBytes, err := os.ReadFile(pubKeyArg)
|
keyBytes, err := os.ReadFile(pubKeyArg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading public key file: %w", err)
|
return fmt.Errorf("reading public key file: %w", err)
|
||||||
|
|||||||
110
config.go
110
config.go
@@ -2,6 +2,7 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -136,6 +137,7 @@ type Config struct {
|
|||||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||||
Firewall *FirewallConfig `json:"firewall"`
|
Firewall *FirewallConfig `json:"firewall"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirewallConfig struct {
|
type FirewallConfig struct {
|
||||||
@@ -190,6 +192,10 @@ type WireguardConfig struct {
|
|||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
const sdConfigPath = "/mnt/sdcard/kvm_config.json"
|
||||||
|
|
||||||
|
// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification,
|
||||||
|
// injected via -ldflags at build time. Empty string disables signature verification.
|
||||||
|
var builtOtaPublicKey = ""
|
||||||
|
|
||||||
var defaultConfig = &Config{
|
var defaultConfig = &Config{
|
||||||
STUN: "stun:stun.l.google.com:19302",
|
STUN: "stun:stun.l.google.com:19302",
|
||||||
TurnServers: []TurnServer{},
|
TurnServers: []TurnServer{},
|
||||||
@@ -252,6 +258,75 @@ var (
|
|||||||
configLock = &sync.Mutex{}
|
configLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error)
|
||||||
|
|
||||||
|
var configMigrations = []ConfigMigrationFunc{
|
||||||
|
migrateLocalAuthMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var rawMap map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authModeRaw, exists := rawMap["localAuthMode"]; exists {
|
||||||
|
var authMode string
|
||||||
|
if err := json.Unmarshal(authModeRaw, &authMode); err == nil {
|
||||||
|
if authMode != "" {
|
||||||
|
validModes := map[string]bool{"password": true, "noPassword": true}
|
||||||
|
if !validModes[authMode] {
|
||||||
|
rawMap["localAuthMode"] = json.RawMessage(`"password"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete(rawMap, "localAuthMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := json.Marshal(rawMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) {
|
||||||
|
current := raw
|
||||||
|
didMigrate := false
|
||||||
|
for i, migration := range configMigrations {
|
||||||
|
migrated, err := migration(current)
|
||||||
|
if err != nil {
|
||||||
|
return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err)
|
||||||
|
}
|
||||||
|
if string(migrated) != string(current) {
|
||||||
|
didMigrate = true
|
||||||
|
logger.Info().Int("migration", i).Msg("config migrated")
|
||||||
|
}
|
||||||
|
current = migrated
|
||||||
|
}
|
||||||
|
return current, didMigrate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRawConfig(path string, raw json.RawMessage) error {
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var indented bytes.Buffer
|
||||||
|
if err := json.Indent(&indented, raw, "", " "); err != nil {
|
||||||
|
return fmt.Errorf("failed to indent config JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := indented.WriteTo(file); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func LoadConfig() {
|
func LoadConfig() {
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
defer configLock.Unlock()
|
defer configLock.Unlock()
|
||||||
@@ -261,7 +336,6 @@ func LoadConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// load the default config
|
|
||||||
if defaultConfig.UsbConfig.SerialNumber == "" {
|
if defaultConfig.UsbConfig.SerialNumber == "" {
|
||||||
serialNumber, err := extractSerialNumber()
|
serialNumber, err := extractSerialNumber()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -273,24 +347,38 @@ func LoadConfig() {
|
|||||||
loadedConfig := *defaultConfig
|
loadedConfig := *defaultConfig
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
file, err := os.Open(configPath)
|
rawData, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug().Msg("default config file doesn't exist, using default")
|
logger.Debug().Msg("config file does not exist, using default")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// load and merge the default config with the user config
|
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
|
||||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
|
||||||
os.Remove(configPath)
|
corruptPath := configPath + ".corrupt"
|
||||||
if _, err := os.Stat(sdConfigPath); err == nil {
|
_ = os.Rename(configPath, corruptPath)
|
||||||
os.Remove(sdConfigPath)
|
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if didMigrate {
|
||||||
|
if writeErr := writeRawConfig(configPath, migrated); writeErr != nil {
|
||||||
|
logger.Warn().Err(writeErr).Msg("failed to write migrated config, continuing with in-memory version")
|
||||||
|
} else {
|
||||||
|
logger.Info().Msg("migrated config saved to disk")
|
||||||
|
SyncConfigSD(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(migrated, &loadedConfig); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("config file JSON parsing failed, preserving corrupt file")
|
||||||
|
corruptPath := configPath + ".corrupt"
|
||||||
|
_ = os.Rename(configPath, corruptPath)
|
||||||
|
logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge the user config with the default config
|
|
||||||
if loadedConfig.UsbConfig == nil {
|
if loadedConfig.UsbConfig == nil {
|
||||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||||
}
|
}
|
||||||
|
|||||||
285
ota.go
285
ota.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -42,8 +43,10 @@ type RemoteMetadata struct {
|
|||||||
AppVersion string `json:"appVersion"`
|
AppVersion string `json:"appVersion"`
|
||||||
AppUrl string `json:"appUrl"`
|
AppUrl string `json:"appUrl"`
|
||||||
AppHash string `json:"appHash"`
|
AppHash string `json:"appHash"`
|
||||||
|
AppSigUrl string `json:"appSigUrl,omitempty"`
|
||||||
SystemUrl string `json:"systemUrl"`
|
SystemUrl string `json:"systemUrl"`
|
||||||
SystemHash string `json:"systemHash,omitempty"`
|
SystemHash string `json:"systemHash,omitempty"`
|
||||||
|
SystemSigUrl string `json:"systemSigUrl,omitempty"`
|
||||||
SystemVersion string `json:"systemVersion"`
|
SystemVersion string `json:"systemVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,8 @@ type UpdateStatus struct {
|
|||||||
Remote *RemoteMetadata `json:"remote"`
|
Remote *RemoteMetadata `json:"remote"`
|
||||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||||
|
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||||
|
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||||
|
|
||||||
// for backwards compatibility
|
// for backwards compatibility
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{
|
|||||||
|
|
||||||
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
|
||||||
|
|
||||||
var builtAppVersion = "0.1.2+dev"
|
var builtAppVersion = "0.1.3+dev"
|
||||||
|
|
||||||
var updateSource = "github"
|
var (
|
||||||
var customUpdateBaseURL string
|
updateSource = "github"
|
||||||
|
customUpdateBaseURL string
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
updateSourceGithub = "github"
|
updateSourceGithub = "github"
|
||||||
@@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
|
|
||||||
_, _ = deviceId, includePreRelease
|
_, _ = deviceId, includePreRelease
|
||||||
|
|
||||||
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
|
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
|
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppVersion: appVersionRemote,
|
AppVersion: appVersionRemote,
|
||||||
AppHash: appSha256,
|
AppHash: appSha256,
|
||||||
|
AppSigUrl: appSigURL,
|
||||||
SystemUrl: systemZipURL,
|
SystemUrl: systemZipURL,
|
||||||
SystemVersion: systemVersionRemote,
|
SystemVersion: systemVersionRemote,
|
||||||
|
SystemSigUrl: systemSigURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) {
|
func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) {
|
||||||
apiURLs := UpdateGithubAppReleaseUrls
|
apiURLs := UpdateGithubAppReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
fallbackToGithub = true
|
fallbackToGithub = true
|
||||||
}
|
}
|
||||||
|
|
||||||
tryFetch := func(urls []string) (string, string, string, error) {
|
tryFetch := func(urls []string) (string, string, string, string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, apiURL := range urls {
|
for _, apiURL := range urls {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
@@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadURL string
|
var downloadURL, sha256, sigURL string
|
||||||
var sha256 string
|
for _, asset := range release.Assets {
|
||||||
if len(release.Assets) > 0 {
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
downloadURL = release.Assets[0].BrowserDownloadURL
|
u := strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
sha256 = release.Assets[0].Digest
|
if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") {
|
||||||
|
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
||||||
|
sigURL = u
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if downloadURL == "" {
|
||||||
|
downloadURL = u
|
||||||
|
sha256 = strings.TrimPrefix(strings.TrimSpace(asset.Digest), "sha256:")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:")
|
|
||||||
|
|
||||||
if strings.TrimSpace(downloadURL) == "" {
|
if strings.TrimSpace(downloadURL) == "" {
|
||||||
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return tag, downloadURL, sha256, nil
|
return tag, downloadURL, sha256, sigURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
lastErr = fmt.Errorf("no app release API URLs configured")
|
lastErr = fmt.Errorf("no app release API URLs configured")
|
||||||
}
|
}
|
||||||
return "", "", "", lastErr
|
return "", "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
tag, downloadURL, sha256, err = tryFetch(apiURLs)
|
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tag, downloadURL, sha256, nil
|
return tag, downloadURL, sha256, sigURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
if updateSource == updateSourceGitee && fallbackToGithub {
|
if updateSource == updateSourceGitee && fallbackToGithub {
|
||||||
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
|
var ghSigURL string
|
||||||
|
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1)
|
||||||
return tag, downloadURL, sha256, nil
|
ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1)
|
||||||
|
return tag, downloadURL, sha256, ghSigURL, nil
|
||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err)
|
||||||
}
|
}
|
||||||
return "", "", "", lastErr
|
return "", "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
type releaseAsset struct {
|
type releaseAsset struct {
|
||||||
@@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) {
|
func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) {
|
||||||
apiURLs := UpdateGithubSystemReleaseUrls
|
apiURLs := UpdateGithubSystemReleaseUrls
|
||||||
fallbackToGithub := false
|
fallbackToGithub := false
|
||||||
if updateSource == updateSourceGitee {
|
if updateSource == updateSourceGitee {
|
||||||
@@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sysSigURL string
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
|
if strings.HasSuffix(name, ".sig") && sysSigURL == "" {
|
||||||
|
sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), nil
|
return tag, strings.TrimSpace(u), sysSigURL, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||||
return tag, strings.TrimSpace(release.ZipballURL), nil
|
return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||||
@@ -355,22 +381,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
var githubTag string
|
var githubTag string
|
||||||
var githubZipURL string
|
var githubZipURL string
|
||||||
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
for i, apiURL := range UpdateGithubSystemReleaseUrls {
|
||||||
githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) {
|
githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("error creating system release request: %w", err)
|
return "", "", "", fmt.Errorf("error creating system release request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("error fetching system release: %w", err)
|
return "", "", "", fmt.Errorf("error fetching system release: %w", err)
|
||||||
}
|
}
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
return "", "", "", fmt.Errorf("error reading system release response: %w", readErr)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", "", fmt.Errorf(
|
return "", "", "", fmt.Errorf(
|
||||||
"unexpected status code fetching system release from %s: %d, %s",
|
"unexpected status code fetching system release from %s: %d, %s",
|
||||||
apiURL,
|
apiURL,
|
||||||
resp.StatusCode,
|
resp.StatusCode,
|
||||||
@@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
Assets []releaseAsset `json:"assets"`
|
Assets []releaseAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &release); err != nil {
|
if err := json.Unmarshal(body, &release); err != nil {
|
||||||
return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err)
|
||||||
}
|
}
|
||||||
tag := strings.TrimSpace(release.TagName)
|
tag := strings.TrimSpace(release.TagName)
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
return "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL)
|
||||||
|
}
|
||||||
|
var sigURL string
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||||
|
if strings.HasSuffix(name, ".sig") && sigURL == "" {
|
||||||
|
sigURL = strings.TrimSpace(asset.BrowserDownloadURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" {
|
||||||
return tag, strings.TrimSpace(u), nil
|
return tag, strings.TrimSpace(u), sigURL, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(release.ZipballURL) != "" {
|
if strings.TrimSpace(release.ZipballURL) != "" {
|
||||||
return tag, strings.TrimSpace(release.ZipballURL), nil
|
return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
return "", "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL)
|
||||||
}(apiURL)
|
}(apiURL)
|
||||||
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
||||||
_ = githubZipURL
|
_ = githubZipURL
|
||||||
@@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
|||||||
zipTag = strings.TrimPrefix(zipTag, "V")
|
zipTag = strings.TrimPrefix(zipTag, "V")
|
||||||
}
|
}
|
||||||
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
||||||
return githubTag, zipURL, nil
|
return githubTag, zipURL, "", nil
|
||||||
}
|
}
|
||||||
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr)
|
||||||
}
|
}
|
||||||
return "", "", lastErr
|
return "", "", "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) {
|
||||||
@@ -496,13 +529,21 @@ func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*Remot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appSigURL, _ := resolveURL(baseURL, "kvm_app.sig")
|
||||||
|
systemSigURL, _ := resolveURL(baseURL, "update_system.zip.sig")
|
||||||
|
if strings.HasSuffix(systemURL, ".tar") {
|
||||||
|
systemSigURL, _ = resolveURL(baseURL, "update_system.tar.sig")
|
||||||
|
}
|
||||||
|
|
||||||
return &RemoteMetadata{
|
return &RemoteMetadata{
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
AppUrl: appURL,
|
AppUrl: appURL,
|
||||||
AppHash: appHash,
|
AppHash: appHash,
|
||||||
|
AppSigUrl: appSigURL,
|
||||||
SystemVersion: systemVersion,
|
SystemVersion: systemVersion,
|
||||||
SystemUrl: systemURL,
|
SystemUrl: systemURL,
|
||||||
SystemHash: systemHash,
|
SystemHash: systemHash,
|
||||||
|
SystemSigUrl: systemSigURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +926,7 @@ func downloadFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the filesystem caches to force a read from disk
|
// Clear the filesystem caches to force a read from disk
|
||||||
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
|
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
||||||
}
|
}
|
||||||
@@ -909,6 +950,8 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
downloadProgress *float32,
|
downloadProgress *float32,
|
||||||
downloadSpeedBps *float32,
|
downloadSpeedBps *float32,
|
||||||
verificationProgress *float32,
|
verificationProgress *float32,
|
||||||
|
sigURL string,
|
||||||
|
expectedHash string,
|
||||||
scopedLogger *zerolog.Logger,
|
scopedLogger *zerolog.Logger,
|
||||||
) error {
|
) error {
|
||||||
if scopedLogger == nil {
|
if scopedLogger == nil {
|
||||||
@@ -920,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
extractDir := filepath.Join(workDir, "extract")
|
extractDir := filepath.Join(workDir, "extract")
|
||||||
zipPath := filepath.Join(workDir, "master.zip")
|
zipPath := filepath.Join(workDir, "master.zip")
|
||||||
|
|
||||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating work dir: %w", err)
|
return fmt.Errorf("error creating work dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.RemoveAll(extractDir); err != nil {
|
if err := os.RemoveAll(extractDir); err != nil {
|
||||||
return fmt.Errorf("error cleaning extract dir: %w", err)
|
return fmt.Errorf("error cleaning extract dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
if err := os.MkdirAll(extractDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating extract dir: %w", err)
|
return fmt.Errorf("error creating extract dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,19 +998,26 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
zipUnverifiedPath := zipPath + ".unverified"
|
zipUnverifiedPath := zipPath + ".unverified"
|
||||||
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
||||||
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
||||||
} else {
|
} else if sigURL != "" || expectedHash != "" {
|
||||||
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil {
|
||||||
|
lastErr = fmt.Errorf("system zip verification failed: %w", err)
|
||||||
|
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
lastErr = nil
|
lastErr = nil
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = os.Remove(zipPath + ".unverified")
|
_ = os.Remove(zipPath + ".unverified")
|
||||||
_ = os.RemoveAll(extractDir)
|
_ = os.RemoveAll(extractDir)
|
||||||
_ = os.MkdirAll(extractDir, 0755)
|
_ = os.MkdirAll(extractDir, 0o755)
|
||||||
if attempt < maxAttempts {
|
if attempt < maxAttempts {
|
||||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||||
}
|
}
|
||||||
@@ -999,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
|||||||
if _, err := os.Stat(scriptPath); err != nil {
|
if _, err := os.Stat(scriptPath); err != nil {
|
||||||
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
return fmt.Errorf("split_and_check_md5.sh not found: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
if err := os.Chmod(scriptPath, 0o755); err != nil {
|
||||||
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
if err := os.MkdirAll(cleanTargetPath, 0755); err != nil {
|
if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating dir: %w", err)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil {
|
||||||
return fmt.Errorf("error creating dir: %w", err)
|
return fmt.Errorf("error creating dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return fmt.Errorf("error opening zipped file: %w", err)
|
return fmt.Errorf("error opening zipped file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rc.Close()
|
rc.Close()
|
||||||
return fmt.Errorf("error creating file: %w", err)
|
return fmt.Errorf("error creating file: %w", err)
|
||||||
@@ -1102,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
||||||
if scopedLogger == nil {
|
if scopedLogger == nil {
|
||||||
scopedLogger = otaLogger
|
scopedLogger = otaLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
unverifiedPath := path + ".unverified"
|
unverifiedPath := path + ".unverified"
|
||||||
|
|
||||||
|
if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" {
|
||||||
|
return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(sigURL) != "" {
|
||||||
|
sigBasePath := path + ".sig"
|
||||||
|
sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil)
|
||||||
|
sigPath := sigBasePath + ".unverified"
|
||||||
|
if sigDownloadErr != nil {
|
||||||
|
scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification")
|
||||||
|
} else {
|
||||||
|
sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger)
|
||||||
|
_ = os.Remove(sigPath)
|
||||||
|
if sigPresent && sigErr != nil {
|
||||||
|
return fmt.Errorf("signature verification failed: %w", sigErr)
|
||||||
|
}
|
||||||
|
if sigPresent && sigErr == nil {
|
||||||
|
scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification")
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(expectedHash) == "" {
|
if strings.TrimSpace(expectedHash) == "" {
|
||||||
if err := os.Rename(unverifiedPath, path); err != nil {
|
if err := os.Rename(unverifiedPath, path); err != nil {
|
||||||
return fmt.Errorf("error renaming file: %w", err)
|
return fmt.Errorf("error renaming file: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(path, 0755); err != nil {
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
return fmt.Errorf("error making file executable: %w", err)
|
return fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1170,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
|
|||||||
return fmt.Errorf("error renaming file: %w", err)
|
return fmt.Errorf("error renaming file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Chmod(path, 0755); err != nil {
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
return fmt.Errorf("error making file executable: %w", err)
|
return fmt.Errorf("error making file executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,19 +1258,23 @@ type OTAState struct {
|
|||||||
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
||||||
AppUpdatePending bool `json:"appUpdatePending"`
|
AppUpdatePending bool `json:"appUpdatePending"`
|
||||||
SystemUpdatePending bool `json:"systemUpdatePending"`
|
SystemUpdatePending bool `json:"systemUpdatePending"`
|
||||||
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
|
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||||
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
|
AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
|
||||||
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
|
||||||
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
|
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` // TODO: implement for progress bar
|
||||||
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
|
SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
|
||||||
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
||||||
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
||||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||||
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
|
||||||
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
|
||||||
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
|
AppSignatureVerified bool `json:"appSignatureVerified,omitempty"`
|
||||||
|
SystemSignatureVerified bool `json:"systemSignatureVerified,omitempty"`
|
||||||
|
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||||
|
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||||
|
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` // TODO: implement for progress bar
|
||||||
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
|
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
|
||||||
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
|
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` // TODO: port rk_ota, then implement
|
||||||
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
|
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,7 +1293,11 @@ func triggerOTAStateUpdate() {
|
|||||||
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/userdata/picokvm/bin/kvm_app.unverified",
|
"/userdata/picokvm/bin/kvm_app.unverified",
|
||||||
|
"/userdata/picokvm/bin/kvm_app.sig.unverified",
|
||||||
|
"/userdata/picokvm/update_system.zip.unverified",
|
||||||
|
"/userdata/picokvm/update_system.zip.sig.unverified",
|
||||||
"/userdata/picokvm/update_system.tar.unverified",
|
"/userdata/picokvm/update_system.tar.unverified",
|
||||||
|
"/userdata/picokvm/update_system.tar.sig.unverified",
|
||||||
"/userdata/picokvm/update_system.tar",
|
"/userdata/picokvm/update_system.tar",
|
||||||
"/userdata/picokvm/kvm_system_work",
|
"/userdata/picokvm/kvm_system_work",
|
||||||
}
|
}
|
||||||
@@ -1298,8 +1381,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
err = verifyFile(
|
err = verifyFile(
|
||||||
|
ctx,
|
||||||
"/userdata/picokvm/bin/kvm_app",
|
"/userdata/picokvm/bin/kvm_app",
|
||||||
remote.AppHash,
|
remote.AppHash,
|
||||||
|
remote.AppSigUrl,
|
||||||
&otaState.AppVerificationProgress,
|
&otaState.AppVerificationProgress,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
@@ -1312,6 +1397,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.AppVerifiedAt = &verifyFinished
|
otaState.AppVerifiedAt = &verifyFinished
|
||||||
otaState.AppVerificationProgress = 1
|
otaState.AppVerificationProgress = 1
|
||||||
|
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
|
||||||
otaState.AppUpdatedAt = &verifyFinished
|
otaState.AppUpdatedAt = &verifyFinished
|
||||||
otaState.AppUpdateProgress = 1
|
otaState.AppUpdateProgress = 1
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
@@ -1337,6 +1423,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
&otaState.SystemDownloadProgress,
|
&otaState.SystemDownloadProgress,
|
||||||
&otaState.SystemDownloadSpeedBps,
|
&otaState.SystemDownloadSpeedBps,
|
||||||
&otaState.SystemVerificationProgress,
|
&otaState.SystemVerificationProgress,
|
||||||
|
remote.SystemSigUrl,
|
||||||
|
remote.SystemHash,
|
||||||
&scopedLogger,
|
&scopedLogger,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1361,7 +1449,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger)
|
err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
||||||
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
scopedLogger.Error().Err(err).Msg("Error preparing system update archive")
|
||||||
@@ -1385,6 +1473,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.SystemVerifiedAt = &verifyFinished
|
otaState.SystemVerifiedAt = &verifyFinished
|
||||||
otaState.SystemVerificationProgress = 1
|
otaState.SystemVerificationProgress = 1
|
||||||
|
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
scopedLogger.Info().Msg("Starting rk_ota command")
|
||||||
@@ -1454,13 +1543,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rebootNeeded {
|
if rebootNeeded {
|
||||||
configPath := "/userdata/kvm_config.json"
|
|
||||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot")
|
|
||||||
} else {
|
|
||||||
scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot")
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("System Rebooting in 10s")
|
scopedLogger.Info().Msg("System Rebooting in 10s")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
cmd := exec.Command("reboot")
|
cmd := exec.Command("reboot")
|
||||||
@@ -1521,6 +1603,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
|
|||||||
updateStatus.AppUpdateAvailable = false
|
updateStatus.AppUpdateAvailable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
|
||||||
|
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
|
||||||
|
|
||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,3 +1620,81 @@ func confirmCurrentSystem() {
|
|||||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOTAPublicKey() ed25519.PublicKey {
|
||||||
|
keyStr := strings.TrimSpace(builtOtaPublicKey)
|
||||||
|
if keyStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keyBytes, err := hex.DecodeString(keyStr)
|
||||||
|
if err != nil {
|
||||||
|
otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(keyBytes) != ed25519.PublicKeySize {
|
||||||
|
otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(keyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyFileSignature(
|
||||||
|
unverifiedPath string,
|
||||||
|
sigPath string,
|
||||||
|
scopedLogger *zerolog.Logger,
|
||||||
|
) (signaturePresent bool, err error) {
|
||||||
|
if scopedLogger == nil {
|
||||||
|
scopedLogger = otaLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(sigPath); os.IsNotExist(err) {
|
||||||
|
scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sigBytes, err := os.ReadFile(sigPath)
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("error reading signature file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := getOTAPublicKey()
|
||||||
|
if publicKey == nil {
|
||||||
|
return true, fmt.Errorf("signature present but no public key embedded in binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBytes, err := os.ReadFile(unverifiedPath)
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("error reading file for signature verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ed25519.Verify(publicKey, fileBytes, sigBytes) {
|
||||||
|
return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSigFileAbsent(sigPath string) bool {
|
||||||
|
_, err := os.Stat(sigPath)
|
||||||
|
return os.IsNotExist(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool {
|
||||||
|
sigBytes, err := os.ReadFile(sigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fileBytes, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ed25519.Verify(publicKey, fileBytes, sigBytes)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Constants and types
|
// Constants and types
|
||||||
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo"
|
||||||
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
|
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
|
||||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -189,9 +189,6 @@ interface RTCState {
|
|||||||
|
|
||||||
serialConsole: RTCDataChannel | null;
|
serialConsole: RTCDataChannel | null;
|
||||||
setSerialConsole: (channel: RTCDataChannel | null) => void;
|
setSerialConsole: (channel: RTCDataChannel | null) => void;
|
||||||
|
|
||||||
hidChannel: RTCDataChannel | null;
|
|
||||||
setHidChannel: (channel: RTCDataChannel | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRTCStore = create<RTCState>(set => ({
|
export const useRTCStore = create<RTCState>(set => ({
|
||||||
@@ -201,9 +198,6 @@ export const useRTCStore = create<RTCState>(set => ({
|
|||||||
rpcDataChannel: null,
|
rpcDataChannel: null,
|
||||||
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
|
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
|
||||||
|
|
||||||
hidChannel: null,
|
|
||||||
setHidChannel: channel => set({ hidChannel: channel }),
|
|
||||||
|
|
||||||
transceiver: null,
|
transceiver: null,
|
||||||
setTransceiver: transceiver => set({ transceiver }),
|
setTransceiver: transceiver => set({ transceiver }),
|
||||||
|
|
||||||
@@ -592,9 +586,6 @@ export interface HidState {
|
|||||||
keyboardLedStateSyncAvailable: boolean;
|
keyboardLedStateSyncAvailable: boolean;
|
||||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||||
|
|
||||||
rpcHidReady: boolean;
|
|
||||||
setRpcHidReady: (ready: boolean) => void;
|
|
||||||
|
|
||||||
keysDownState?: { modifier: number; keys: number[] };
|
keysDownState?: { modifier: number; keys: number[] };
|
||||||
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
||||||
|
|
||||||
@@ -656,9 +647,6 @@ export const useHidStore = create<HidState>((set, get) => ({
|
|||||||
set({ keyboardLedState });
|
set({ keyboardLedState });
|
||||||
},
|
},
|
||||||
|
|
||||||
rpcHidReady: false,
|
|
||||||
setRpcHidReady: ready => set({ rpcHidReady: ready }),
|
|
||||||
|
|
||||||
keysDownState: undefined,
|
keysDownState: undefined,
|
||||||
setKeysDownState: state => set({ keysDownState: state }),
|
setKeysDownState: state => set({ keysDownState: state }),
|
||||||
|
|
||||||
@@ -741,6 +729,13 @@ export interface UpdateState {
|
|||||||
|
|
||||||
systemUpdateProgress: number;
|
systemUpdateProgress: number;
|
||||||
systemUpdatedAt: string | null;
|
systemUpdatedAt: string | null;
|
||||||
|
|
||||||
|
appSignatureMissing: boolean;
|
||||||
|
systemSignatureMissing: boolean;
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
};
|
};
|
||||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||||
@@ -789,6 +784,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||||||
appUpdatedAt: null,
|
appUpdatedAt: null,
|
||||||
systemUpdateProgress: 0,
|
systemUpdateProgress: 0,
|
||||||
systemUpdatedAt: null,
|
systemUpdatedAt: null,
|
||||||
|
appSignatureMissing: false,
|
||||||
|
systemSignatureMissing: false,
|
||||||
|
appSignatureAbsent: false,
|
||||||
|
appSignatureInvalid: false,
|
||||||
|
appNoPublicKey: false,
|
||||||
|
signatureVerified: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDialogHasBeenMinimized: false,
|
updateDialogHasBeenMinimized: false,
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface SystemVersionInfo {
|
|||||||
remote?: { appVersion: string; systemVersion: string };
|
remote?: { appVersion: string; systemVersion: string };
|
||||||
systemUpdateAvailable: boolean;
|
systemUpdateAvailable: boolean;
|
||||||
appUpdateAvailable: boolean;
|
appUpdateAvailable: boolean;
|
||||||
|
appSignatureMissing?: boolean;
|
||||||
|
systemSignatureMissing?: boolean;
|
||||||
|
appSignatureAbsent?: boolean;
|
||||||
|
systemSignatureAbsent?: boolean;
|
||||||
|
appSignatureInvalid?: boolean;
|
||||||
|
systemSignatureInvalid?: boolean;
|
||||||
|
appNoPublicKey?: boolean;
|
||||||
|
systemNoPublicKey?: boolean;
|
||||||
|
signatureVerified?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +47,13 @@ export default function SettingsVersion() {
|
|||||||
const { bootStorageType } = useBootStorageType();
|
const { bootStorageType } = useBootStorageType();
|
||||||
const isBootFromSD = bootStorageType === "sd";
|
const isBootFromSD = bootStorageType === "sd";
|
||||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
const [signatureStatusLoading, setSignatureStatusLoading] = useState(true);
|
||||||
|
const [signatureStatus, setSignatureStatus] = useState<{
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
|
} | null>(null);
|
||||||
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
const updatePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [updateSource, setUpdateSource] = useState("github");
|
const [updateSource, setUpdateSource] = useState("github");
|
||||||
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
|
||||||
@@ -82,6 +98,23 @@ export default function SettingsVersion() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureStatusLoading(true);
|
||||||
|
send("getSelfSignatureStatus", {}, resp => {
|
||||||
|
setSignatureStatusLoading(false);
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const sigStatus = resp.result as {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
};
|
||||||
|
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||||
|
const noPublicKey = sigStatus.appNoPublicKey;
|
||||||
|
const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid;
|
||||||
|
setSignatureStatus({ ...sigStatus, signatureVerified });
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
const applyUpdateSource = useCallback(
|
const applyUpdateSource = useCallback(
|
||||||
(source: string) => {
|
(source: string) => {
|
||||||
send("setUpdateSource", { source }, resp => {
|
send("setUpdateSource", { source }, resp => {
|
||||||
@@ -205,6 +238,11 @@ export default function SettingsVersion() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SignatureStatusCard
|
||||||
|
signatureStatus={signatureStatus}
|
||||||
|
signatureStatusLoading={signatureStatusLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{!isBootFromSD && (
|
{!isBootFromSD && (
|
||||||
<>
|
<>
|
||||||
<UpdateSourceSettings
|
<UpdateSourceSettings
|
||||||
@@ -382,6 +420,7 @@ function UpdateContent({
|
|||||||
<SystemUpToDateState
|
<SystemUpToDateState
|
||||||
checkUpdate={() => setModalView("loading")}
|
checkUpdate={() => setModalView("loading")}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
versionInfo={versionInfo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -407,23 +446,58 @@ function LoadingState({
|
|||||||
|
|
||||||
const getVersionInfo = useCallback(() => {
|
const getVersionInfo = useCallback(() => {
|
||||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||||
send("getUpdateStatus", {}, async resp => {
|
Promise.all([
|
||||||
if ("error" in resp) {
|
new Promise<SystemVersionInfo>((res, rej) => {
|
||||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
send("getUpdateStatus", {}, resp => {
|
||||||
reject(new Error("Failed to check for updates"));
|
if ("error" in resp) {
|
||||||
} else {
|
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||||
const result = resp.result as SystemVersionInfo;
|
rej(new Error("Failed to check for updates"));
|
||||||
setAppVersion(result.local.appVersion);
|
} else {
|
||||||
setSystemVersion(result.local.systemVersion);
|
const result = resp.result as SystemVersionInfo;
|
||||||
|
setAppVersion(result.local.appVersion);
|
||||||
if (result.error) {
|
setSystemVersion(result.local.systemVersion);
|
||||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
if (result.error) {
|
||||||
reject(new Error("Failed to check for updates"));
|
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||||
} else {
|
rej(new Error("Failed to check for updates"));
|
||||||
resolve(result);
|
} else {
|
||||||
}
|
res(result);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
new Promise<SystemVersionInfo>((res, rej) => {
|
||||||
|
send("getSelfSignatureStatus", {}, resp => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
rej(new Error("Failed to get signature status"));
|
||||||
|
} else {
|
||||||
|
const sigStatus = resp.result as {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
};
|
||||||
|
const hasSigFiles = !sigStatus.appSignatureAbsent;
|
||||||
|
const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid;
|
||||||
|
const partial: Partial<SystemVersionInfo> = {
|
||||||
|
appSignatureAbsent: sigStatus.appSignatureAbsent,
|
||||||
|
appSignatureInvalid: sigStatus.appSignatureInvalid,
|
||||||
|
appNoPublicKey: sigStatus.appNoPublicKey,
|
||||||
|
signatureVerified,
|
||||||
|
};
|
||||||
|
res(partial as SystemVersionInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(([versionResult, sigResult]) => {
|
||||||
|
resolve({
|
||||||
|
...versionResult,
|
||||||
|
appSignatureAbsent: sigResult.appSignatureAbsent,
|
||||||
|
appSignatureInvalid: sigResult.appSignatureInvalid,
|
||||||
|
appNoPublicKey: sigResult.appNoPublicKey,
|
||||||
|
signatureVerified: sigResult.signatureVerified,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
});
|
});
|
||||||
}, [send, setAppVersion, setSystemVersion]);
|
}, [send, setAppVersion, setSystemVersion]);
|
||||||
|
|
||||||
@@ -669,11 +743,17 @@ function UpdatingDeviceState({
|
|||||||
function SystemUpToDateState({
|
function SystemUpToDateState({
|
||||||
checkUpdate,
|
checkUpdate,
|
||||||
onClose,
|
onClose,
|
||||||
|
versionInfo,
|
||||||
}: {
|
}: {
|
||||||
checkUpdate: () => void;
|
checkUpdate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
versionInfo: SystemVersionInfo | null;
|
||||||
}) {
|
}) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
|
const hasAbsentSig = versionInfo?.appSignatureAbsent;
|
||||||
|
const hasInvalidSig = versionInfo?.appSignatureInvalid;
|
||||||
|
const hasNoPublicKey = versionInfo?.appNoPublicKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -684,6 +764,50 @@ function SystemUpToDateState({
|
|||||||
{$at("Your system is running the latest version. No updates are currently available.")}
|
{$at("Your system is running the latest version. No updates are currently available.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{hasAbsentSig && (
|
||||||
|
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Missing Signature File")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasInvalidSig && (
|
||||||
|
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{$at("Signature Verification Failed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||||
|
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNoPublicKey && (
|
||||||
|
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("No Embedded Public Key")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{versionInfo?.signatureVerified && (
|
||||||
|
<div className="mt-4 rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
{$at("Signature Verified")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||||
|
{$at("Firmware signature has been verified and is valid.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex gap-x-2">
|
<div className="mt-4 flex gap-x-2">
|
||||||
<AntdButton type="primary" onClick={checkUpdate}>
|
<AntdButton type="primary" onClick={checkUpdate}>
|
||||||
{$at("Check Again")}
|
{$at("Check Again")}
|
||||||
@@ -825,3 +949,101 @@ function UpdateErrorState({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SignatureStatusCard({
|
||||||
|
signatureStatus,
|
||||||
|
signatureStatusLoading,
|
||||||
|
}: {
|
||||||
|
signatureStatus: {
|
||||||
|
appSignatureAbsent: boolean;
|
||||||
|
appSignatureInvalid: boolean;
|
||||||
|
appNoPublicKey: boolean;
|
||||||
|
signatureVerified: boolean;
|
||||||
|
} | null;
|
||||||
|
signatureStatusLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const { $at } = useReactAt();
|
||||||
|
if (signatureStatusLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{$at("Verifying signature...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{$at("Please wait while verifying firmware signature.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signatureStatus) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Signature Status Unavailable")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("Unable to retrieve signature verification status.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.signatureVerified) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
|
||||||
|
{$at("Signature Verified")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||||
|
{$at("Firmware signature has been verified and is valid.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appSignatureAbsent) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("Missing Signature File")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appSignatureInvalid) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{$at("Signature Verification Failed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||||
|
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureStatus.appNoPublicKey) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
{$at("No Embedded Public Key")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
|
||||||
|
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user