mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-26 08:05:08 +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)
|
||||
BUILDTS ?= $(shell date -u +%s)
|
||||
REVISION ?= $(shell git rev-parse HEAD)
|
||||
VERSION_DEV ?= 0.1.2-dev
|
||||
VERSION ?= 0.1.2
|
||||
VERSION_DEV ?= 0.1.3-dev
|
||||
VERSION ?= 0.1.3
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
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_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||
GO_LDFLAGS := \
|
||||
@@ -15,7 +22,8 @@ GO_LDFLAGS := \
|
||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||
-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
|
||||
BIN_DIR := $(shell pwd)/bin
|
||||
@@ -28,6 +36,12 @@ build_dev:
|
||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||
$(GO_RELEASE_BUILD_ARGS) \
|
||||
-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:
|
||||
cd ui && npm ci && npm run build:device
|
||||
@@ -38,3 +52,13 @@ build_release: frontend
|
||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||
$(GO_RELEASE_BUILD_ARGS) \
|
||||
-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{
|
||||
Use: "sign --key <private-key-path> <firmware-file>",
|
||||
Use: "sign --key <private-key> <firmware-file>",
|
||||
Short: "Sign a firmware file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyPath, _ := cmd.Flags().GetString("key")
|
||||
keyArg, _ := cmd.Flags().GetString("key")
|
||||
filePath := args[0]
|
||||
|
||||
if keyPath == "" {
|
||||
if keyArg == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
|
||||
privateKey, err := os.ReadFile(keyPath)
|
||||
privateKey, err := os.ReadFile(keyArg)
|
||||
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 {
|
||||
@@ -450,9 +453,9 @@ var signerSignCmd = &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",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pubKeyArg, _ := cmd.Flags().GetString("pubkey")
|
||||
filePath := args[0]
|
||||
@@ -461,16 +464,23 @@ var signerVerifyCmd = &cobra.Command{
|
||||
sigPath = args[1]
|
||||
}
|
||||
|
||||
if pubKeyArg == "" {
|
||||
return fmt.Errorf("--pubkey is required")
|
||||
}
|
||||
|
||||
if sigPath == "" {
|
||||
sigPath = filePath + ".sig"
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading public key file: %w", err)
|
||||
|
||||
110
config.go
110
config.go
@@ -2,6 +2,7 @@ package kvm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -136,6 +137,7 @@ type Config struct {
|
||||
WireguardConfig WireguardConfig `json:"wireguard_config"`
|
||||
NpuAppEnabled bool `json:"npu_app_enabled"`
|
||||
Firewall *FirewallConfig `json:"firewall"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
type FirewallConfig struct {
|
||||
@@ -190,6 +192,10 @@ type WireguardConfig struct {
|
||||
const configPath = "/userdata/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{
|
||||
STUN: "stun:stun.l.google.com:19302",
|
||||
TurnServers: []TurnServer{},
|
||||
@@ -252,6 +258,75 @@ var (
|
||||
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() {
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
@@ -261,7 +336,6 @@ func LoadConfig() {
|
||||
return
|
||||
}
|
||||
|
||||
// load the default config
|
||||
if defaultConfig.UsbConfig.SerialNumber == "" {
|
||||
serialNumber, err := extractSerialNumber()
|
||||
if err != nil {
|
||||
@@ -273,24 +347,38 @@ func LoadConfig() {
|
||||
loadedConfig := *defaultConfig
|
||||
config = &loadedConfig
|
||||
|
||||
file, err := os.Open(configPath)
|
||||
rawData, err := os.ReadFile(configPath)
|
||||
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
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// load and merge the default config with the user config
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||
os.Remove(configPath)
|
||||
if _, err := os.Stat(sdConfigPath); err == nil {
|
||||
os.Remove(sdConfigPath)
|
||||
migrated, didMigrate, err := runMigrations(json.RawMessage(rawData))
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file")
|
||||
corruptPath := configPath + ".corrupt"
|
||||
_ = os.Rename(configPath, corruptPath)
|
||||
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
|
||||
}
|
||||
|
||||
// merge the user config with the default config
|
||||
if loadedConfig.UsbConfig == nil {
|
||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||
}
|
||||
|
||||
285
ota.go
285
ota.go
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
@@ -42,8 +43,10 @@ type RemoteMetadata struct {
|
||||
AppVersion string `json:"appVersion"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppHash string `json:"appHash"`
|
||||
AppSigUrl string `json:"appSigUrl,omitempty"`
|
||||
SystemUrl string `json:"systemUrl"`
|
||||
SystemHash string `json:"systemHash,omitempty"`
|
||||
SystemSigUrl string `json:"systemSigUrl,omitempty"`
|
||||
SystemVersion string `json:"systemVersion"`
|
||||
}
|
||||
|
||||
@@ -53,6 +56,8 @@ type UpdateStatus struct {
|
||||
Remote *RemoteMetadata `json:"remote"`
|
||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||
AppSignatureMissing bool `json:"appSignatureMissing,omitempty"`
|
||||
SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"`
|
||||
|
||||
// for backwards compatibility
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{
|
||||
|
||||
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 customUpdateBaseURL string
|
||||
var (
|
||||
updateSource = "github"
|
||||
customUpdateBaseURL string
|
||||
)
|
||||
|
||||
const (
|
||||
updateSourceGithub = "github"
|
||||
@@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||
|
||||
_, _ = deviceId, includePreRelease
|
||||
|
||||
appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx)
|
||||
appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||
systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||
AppUrl: appURL,
|
||||
AppVersion: appVersionRemote,
|
||||
AppHash: appSha256,
|
||||
AppSigUrl: appSigURL,
|
||||
SystemUrl: systemZipURL,
|
||||
SystemVersion: systemVersionRemote,
|
||||
SystemSigUrl: systemSigURL,
|
||||
}, 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
|
||||
fallbackToGithub := false
|
||||
if updateSource == updateSourceGitee {
|
||||
@@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
||||
fallbackToGithub = true
|
||||
}
|
||||
|
||||
tryFetch := func(urls []string) (string, string, string, error) {
|
||||
tryFetch := func(urls []string) (string, string, string, string, error) {
|
||||
var lastErr error
|
||||
for _, apiURL := range urls {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
@@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri
|
||||
continue
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
var sha256 string
|
||||
if len(release.Assets) > 0 {
|
||||
downloadURL = release.Assets[0].BrowserDownloadURL
|
||||
sha256 = release.Assets[0].Digest
|
||||
var downloadURL, sha256, sigURL string
|
||||
for _, asset := range release.Assets {
|
||||
name := strings.ToLower(strings.TrimSpace(asset.Name))
|
||||
u := strings.TrimSpace(asset.BrowserDownloadURL)
|
||||
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) == "" {
|
||||
lastErr = fmt.Errorf("empty app download url from %s", apiURL)
|
||||
continue
|
||||
}
|
||||
|
||||
return tag, downloadURL, sha256, nil
|
||||
return tag, downloadURL, sha256, sigURL, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no app release API URLs configured")
|
||||
}
|
||||
return "", "", "", lastErr
|
||||
return "", "", "", "", lastErr
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
tag, downloadURL, sha256, err = tryFetch(apiURLs)
|
||||
tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs)
|
||||
if err == nil {
|
||||
return tag, downloadURL, sha256, nil
|
||||
return tag, downloadURL, sha256, sigURL, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if updateSource == updateSourceGitee && fallbackToGithub {
|
||||
tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||
var ghSigURL string
|
||||
tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls)
|
||||
if err == nil {
|
||||
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)
|
||||
}
|
||||
return "", "", "", lastErr
|
||||
return "", "", "", "", lastErr
|
||||
}
|
||||
|
||||
type releaseAsset struct {
|
||||
@@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string {
|
||||
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
|
||||
fallbackToGithub := false
|
||||
if updateSource == updateSourceGitee {
|
||||
@@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
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) != "" {
|
||||
return tag, strings.TrimSpace(u), nil
|
||||
return tag, strings.TrimSpace(u), sysSigURL, nil
|
||||
}
|
||||
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)
|
||||
@@ -355,22 +381,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
var githubTag string
|
||||
var githubZipURL string
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
resp.Body.Close()
|
||||
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 {
|
||||
return "", "", fmt.Errorf(
|
||||
return "", "", "", fmt.Errorf(
|
||||
"unexpected status code fetching system release from %s: %d, %s",
|
||||
apiURL,
|
||||
resp.StatusCode,
|
||||
@@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
Assets []releaseAsset `json:"assets"`
|
||||
}
|
||||
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)
|
||||
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) != "" {
|
||||
return tag, strings.TrimSpace(u), nil
|
||||
return tag, strings.TrimSpace(u), sigURL, nil
|
||||
}
|
||||
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)
|
||||
if githubErr == nil && strings.TrimSpace(githubTag) != "" {
|
||||
_ = githubZipURL
|
||||
@@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string
|
||||
zipTag = strings.TrimPrefix(zipTag, "V")
|
||||
}
|
||||
zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip"
|
||||
return githubTag, zipURL, nil
|
||||
return githubTag, zipURL, "", nil
|
||||
}
|
||||
githubErr = fmt.Errorf("no gitee system zip urls configured")
|
||||
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) {
|
||||
@@ -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{
|
||||
AppVersion: appVersion,
|
||||
AppUrl: appURL,
|
||||
AppHash: appHash,
|
||||
AppSigUrl: appSigURL,
|
||||
SystemVersion: systemVersion,
|
||||
SystemUrl: systemURL,
|
||||
SystemHash: systemHash,
|
||||
SystemSigUrl: systemSigURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -885,7 +926,7 @@ func downloadFile(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches")
|
||||
}
|
||||
@@ -909,6 +950,8 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
downloadProgress *float32,
|
||||
downloadSpeedBps *float32,
|
||||
verificationProgress *float32,
|
||||
sigURL string,
|
||||
expectedHash string,
|
||||
scopedLogger *zerolog.Logger,
|
||||
) error {
|
||||
if scopedLogger == nil {
|
||||
@@ -920,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
extractDir := filepath.Join(workDir, "extract")
|
||||
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)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(extractDir); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -955,19 +998,26 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
zipUnverifiedPath := zipPath + ".unverified"
|
||||
if _, err := os.Stat(zipUnverifiedPath); err != nil {
|
||||
lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err)
|
||||
} else {
|
||||
if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||
} else if sigURL != "" || expectedHash != "" {
|
||||
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
|
||||
} else {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
} else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Remove(zipPath + ".unverified")
|
||||
_ = os.RemoveAll(extractDir)
|
||||
_ = os.MkdirAll(extractDir, 0755)
|
||||
_ = os.MkdirAll(extractDir, 0o755)
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(time.Duration(attempt*2) * time.Second)
|
||||
}
|
||||
@@ -999,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
|
||||
if _, err := os.Stat(scriptPath); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1064,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1079,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
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 {
|
||||
rc.Close()
|
||||
return fmt.Errorf("error creating file: %w", err)
|
||||
@@ -1102,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error {
|
||||
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 {
|
||||
scopedLogger = otaLogger
|
||||
}
|
||||
|
||||
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 err := os.Rename(unverifiedPath, path); err != nil {
|
||||
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 nil
|
||||
@@ -1170,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1183,19 +1258,23 @@ type OTAState struct {
|
||||
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
|
||||
AppUpdatePending bool `json:"appUpdatePending"`
|
||||
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"`
|
||||
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"`
|
||||
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
|
||||
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
|
||||
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
|
||||
SystemVerificationProgress float32 `json:"systemVerificationProgress,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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -1214,7 +1293,11 @@ func triggerOTAStateUpdate() {
|
||||
func cleanupUpdateTempFiles(logger *zerolog.Logger) {
|
||||
paths := []string{
|
||||
"/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.sig.unverified",
|
||||
"/userdata/picokvm/update_system.tar",
|
||||
"/userdata/picokvm/kvm_system_work",
|
||||
}
|
||||
@@ -1298,8 +1381,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
triggerOTAStateUpdate()
|
||||
|
||||
err = verifyFile(
|
||||
ctx,
|
||||
"/userdata/picokvm/bin/kvm_app",
|
||||
remote.AppHash,
|
||||
remote.AppSigUrl,
|
||||
&otaState.AppVerificationProgress,
|
||||
&scopedLogger,
|
||||
)
|
||||
@@ -1312,6 +1397,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
verifyFinished := time.Now()
|
||||
otaState.AppVerifiedAt = &verifyFinished
|
||||
otaState.AppVerificationProgress = 1
|
||||
otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != ""
|
||||
otaState.AppUpdatedAt = &verifyFinished
|
||||
otaState.AppUpdateProgress = 1
|
||||
triggerOTAStateUpdate()
|
||||
@@ -1337,6 +1423,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
&otaState.SystemDownloadProgress,
|
||||
&otaState.SystemDownloadSpeedBps,
|
||||
&otaState.SystemVerificationProgress,
|
||||
remote.SystemSigUrl,
|
||||
remote.SystemHash,
|
||||
&scopedLogger,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1361,7 +1449,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) 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 {
|
||||
otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err)
|
||||
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()
|
||||
otaState.SystemVerifiedAt = &verifyFinished
|
||||
otaState.SystemVerificationProgress = 1
|
||||
otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != ""
|
||||
triggerOTAStateUpdate()
|
||||
|
||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
||||
@@ -1454,13 +1543,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||
}
|
||||
|
||||
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")
|
||||
time.Sleep(10 * time.Second)
|
||||
cmd := exec.Command("reboot")
|
||||
@@ -1521,6 +1603,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo
|
||||
updateStatus.AppUpdateAvailable = false
|
||||
}
|
||||
|
||||
updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == ""
|
||||
updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == ""
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo"
|
||||
|"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null;
|
||||
export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo"
|
||||
| "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null;
|
||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||
|
||||
export interface User {
|
||||
@@ -189,9 +189,6 @@ interface RTCState {
|
||||
|
||||
serialConsole: RTCDataChannel | null;
|
||||
setSerialConsole: (channel: RTCDataChannel | null) => void;
|
||||
|
||||
hidChannel: RTCDataChannel | null;
|
||||
setHidChannel: (channel: RTCDataChannel | null) => void;
|
||||
}
|
||||
|
||||
export const useRTCStore = create<RTCState>(set => ({
|
||||
@@ -201,9 +198,6 @@ export const useRTCStore = create<RTCState>(set => ({
|
||||
rpcDataChannel: null,
|
||||
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
|
||||
|
||||
hidChannel: null,
|
||||
setHidChannel: channel => set({ hidChannel: channel }),
|
||||
|
||||
transceiver: null,
|
||||
setTransceiver: transceiver => set({ transceiver }),
|
||||
|
||||
@@ -592,9 +586,6 @@ export interface HidState {
|
||||
keyboardLedStateSyncAvailable: boolean;
|
||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||
|
||||
rpcHidReady: boolean;
|
||||
setRpcHidReady: (ready: boolean) => void;
|
||||
|
||||
keysDownState?: { modifier: number; keys: number[] };
|
||||
setKeysDownState: (state: { modifier: number; keys: number[] }) => void;
|
||||
|
||||
@@ -656,9 +647,6 @@ export const useHidStore = create<HidState>((set, get) => ({
|
||||
set({ keyboardLedState });
|
||||
},
|
||||
|
||||
rpcHidReady: false,
|
||||
setRpcHidReady: ready => set({ rpcHidReady: ready }),
|
||||
|
||||
keysDownState: undefined,
|
||||
setKeysDownState: state => set({ keysDownState: state }),
|
||||
|
||||
@@ -741,6 +729,13 @@ export interface UpdateState {
|
||||
|
||||
systemUpdateProgress: number;
|
||||
systemUpdatedAt: string | null;
|
||||
|
||||
appSignatureMissing: boolean;
|
||||
systemSignatureMissing: boolean;
|
||||
appSignatureAbsent: boolean;
|
||||
appSignatureInvalid: boolean;
|
||||
appNoPublicKey: boolean;
|
||||
signatureVerified: boolean;
|
||||
};
|
||||
setOtaState: (state: UpdateState["otaState"]) => void;
|
||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
|
||||
@@ -789,6 +784,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
||||
appUpdatedAt: null,
|
||||
systemUpdateProgress: 0,
|
||||
systemUpdatedAt: null,
|
||||
appSignatureMissing: false,
|
||||
systemSignatureMissing: false,
|
||||
appSignatureAbsent: false,
|
||||
appSignatureInvalid: false,
|
||||
appNoPublicKey: false,
|
||||
signatureVerified: false,
|
||||
},
|
||||
|
||||
updateDialogHasBeenMinimized: false,
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface SystemVersionInfo {
|
||||
remote?: { appVersion: string; systemVersion: string };
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
appSignatureMissing?: boolean;
|
||||
systemSignatureMissing?: boolean;
|
||||
appSignatureAbsent?: boolean;
|
||||
systemSignatureAbsent?: boolean;
|
||||
appSignatureInvalid?: boolean;
|
||||
systemSignatureInvalid?: boolean;
|
||||
appNoPublicKey?: boolean;
|
||||
systemNoPublicKey?: boolean;
|
||||
signatureVerified?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +47,13 @@ export default function SettingsVersion() {
|
||||
const { bootStorageType } = useBootStorageType();
|
||||
const isBootFromSD = bootStorageType === "sd";
|
||||
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 [updateSource, setUpdateSource] = useState("github");
|
||||
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(
|
||||
(source: string) => {
|
||||
send("setUpdateSource", { source }, resp => {
|
||||
@@ -205,6 +238,11 @@ export default function SettingsVersion() {
|
||||
}
|
||||
/>
|
||||
|
||||
<SignatureStatusCard
|
||||
signatureStatus={signatureStatus}
|
||||
signatureStatusLoading={signatureStatusLoading}
|
||||
/>
|
||||
|
||||
{!isBootFromSD && (
|
||||
<>
|
||||
<UpdateSourceSettings
|
||||
@@ -382,6 +420,7 @@ function UpdateContent({
|
||||
<SystemUpToDateState
|
||||
checkUpdate={() => setModalView("loading")}
|
||||
onClose={onClose}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -407,23 +446,58 @@ function LoadingState({
|
||||
|
||||
const getVersionInfo = useCallback(() => {
|
||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||
send("getUpdateStatus", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setAppVersion(result.local.appVersion);
|
||||
setSystemVersion(result.local.systemVersion);
|
||||
|
||||
if (result.error) {
|
||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
Promise.all([
|
||||
new Promise<SystemVersionInfo>((res, rej) => {
|
||||
send("getUpdateStatus", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to check for updates: ${resp.error}`);
|
||||
rej(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setAppVersion(result.local.appVersion);
|
||||
setSystemVersion(result.local.systemVersion);
|
||||
if (result.error) {
|
||||
notifications.error(`Failed to check for updates: ${result.error}`);
|
||||
rej(new Error("Failed to check for updates"));
|
||||
} 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]);
|
||||
|
||||
@@ -669,11 +743,17 @@ function UpdatingDeviceState({
|
||||
function SystemUpToDateState({
|
||||
checkUpdate,
|
||||
onClose,
|
||||
versionInfo,
|
||||
}: {
|
||||
checkUpdate: () => void;
|
||||
onClose: () => void;
|
||||
versionInfo: SystemVersionInfo | null;
|
||||
}) {
|
||||
const { $at } = useReactAt();
|
||||
const hasAbsentSig = versionInfo?.appSignatureAbsent;
|
||||
const hasInvalidSig = versionInfo?.appSignatureInvalid;
|
||||
const hasNoPublicKey = versionInfo?.appNoPublicKey;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 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.")}
|
||||
</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">
|
||||
<AntdButton type="primary" onClick={checkUpdate}>
|
||||
{$at("Check Again")}
|
||||
@@ -825,3 +949,101 @@ function UpdateErrorState({
|
||||
</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