mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat(ota): add custom update server support
+ Add persistent custom update server configuration, authenticated URL support, manifest validation, and the settings UI. + Keep application updates rebootless by coordinating video shutdown, migration-time service startup, and online/offline service restarts. + Localize the custom update server workflow across all supported languages.
This commit is contained in:
@@ -18,7 +18,10 @@ var (
|
||||
kvmVisionOnce sync.Once
|
||||
)
|
||||
|
||||
type KvmVision struct{}
|
||||
type KvmVision struct {
|
||||
mutex sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func GetKvmVision() *KvmVision {
|
||||
kvmVisionOnce.Do(func() {
|
||||
@@ -33,6 +36,12 @@ func GetKvmVision() *KvmVision {
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
@@ -57,6 +66,12 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
@@ -81,6 +96,12 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return -1
|
||||
}
|
||||
|
||||
hdmiEnable := C.uint8_t(0)
|
||||
if enable {
|
||||
hdmiEnable = C.uint8_t(1)
|
||||
@@ -96,16 +117,35 @@ func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetGop(gop uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
_gop := C.uint8_t(gop)
|
||||
C.set_h264_gop(_gop)
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetFrameDetect(frame uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
_frame := C.uint8_t(frame)
|
||||
C.set_frame_detact(_frame)
|
||||
}
|
||||
|
||||
func (k *KvmVision) Close() {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
k.closed = true
|
||||
C.kvmv_deinit()
|
||||
log.Debugf("stop kvm vision...")
|
||||
}
|
||||
|
||||
@@ -12,3 +12,13 @@ type GetPreviewRsp struct {
|
||||
type SetPreviewReq struct {
|
||||
Enable bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type GetUpdateServerRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type SetUpdateServerReq struct {
|
||||
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
|
||||
URL string `json:"url" form:"url"`
|
||||
}
|
||||
|
||||
@@ -17,4 +17,7 @@ func applicationRouter(r *gin.Engine) {
|
||||
|
||||
api.GET("/application/preview", service.GetPreview) // get preview updates state
|
||||
api.POST("/application/preview", service.SetPreview) // set preview updates state
|
||||
|
||||
api.GET("/application/update-server", service.GetUpdateServer) // get custom update server
|
||||
api.POST("/application/update-server", service.SetUpdateServer) // set custom update server
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -41,7 +42,7 @@ func (s *Service) Update(c *gin.Context) {
|
||||
// Sleep for a second before restarting the device
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
_ = exec.Command("sh", "-c", "/kvmapp/system/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func update() error {
|
||||
@@ -58,7 +59,7 @@ func update() error {
|
||||
}
|
||||
|
||||
// download
|
||||
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
|
||||
target := filepath.Join(CacheDir, latest.Name)
|
||||
if err := download(latest.Url, target); err != nil {
|
||||
log.Errorf("download app failed: %s", err)
|
||||
return err
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
|
||||
log.Debugf("offline update application success")
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
_ = exec.Command("sh", "-c", "/kvmapp/system/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func offlineUpdate(c *gin.Context) error {
|
||||
|
||||
212
server/service/application/update_server.go
Normal file
212
server/service/application/update_server.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
UpdateServerConfigFile = "/etc/kvm/application-update.json"
|
||||
maxUpdateServerURLSize = 2048
|
||||
)
|
||||
|
||||
var (
|
||||
updateServerConfigMu sync.Mutex
|
||||
updateServerConfigPath = UpdateServerConfigFile
|
||||
)
|
||||
|
||||
type UpdateServerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func defaultUpdateServerConfig() UpdateServerConfig {
|
||||
return UpdateServerConfig{URL: StableURL}
|
||||
}
|
||||
|
||||
func (s *Service) GetUpdateServer(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
cfg, err := loadUpdateServerConfig()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load update server config: %s", err)
|
||||
rsp.ErrRsp(c, -1, "failed to load update server config")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
|
||||
Enabled: cfg.Enabled,
|
||||
URL: cfg.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetUpdateServer(c *gin.Context) {
|
||||
var req proto.SetUpdateServerReq
|
||||
var rsp proto.Response
|
||||
|
||||
// Avoid the shared request logger because the URL may contain credentials.
|
||||
if err := c.ShouldBind(&req); err != nil || req.Enabled == nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
normalizedURL, err := normalizeUpdateServerURL(req.URL)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
if *req.Enabled && normalizedURL == "" {
|
||||
rsp.ErrRsp(c, -2, "update server URL is required")
|
||||
return
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
normalizedURL = StableURL
|
||||
}
|
||||
|
||||
cfg := UpdateServerConfig{Enabled: *req.Enabled, URL: normalizedURL}
|
||||
if err := saveUpdateServerConfig(cfg); err != nil {
|
||||
log.Errorf("failed to save update server config: %s", err)
|
||||
rsp.ErrRsp(c, -3, "failed to save update server config")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
|
||||
Enabled: cfg.Enabled,
|
||||
URL: cfg.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveUpdateBaseURL() (string, error) {
|
||||
cfg, err := loadUpdateServerConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.Enabled {
|
||||
return cfg.URL, nil
|
||||
}
|
||||
if isPreviewEnabled() {
|
||||
return PreviewURL, nil
|
||||
}
|
||||
return StableURL, nil
|
||||
}
|
||||
|
||||
func normalizeUpdateServerURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(raw) > maxUpdateServerURLSize {
|
||||
return "", errors.New("update server URL is too long")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", errors.New("invalid update server URL")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("update server URL must not contain a query or fragment")
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/latest.json") {
|
||||
return "", errors.New("enter the update server directory, not latest.json")
|
||||
}
|
||||
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func loadUpdateServerConfig() (UpdateServerConfig, error) {
|
||||
updateServerConfigMu.Lock()
|
||||
defer updateServerConfigMu.Unlock()
|
||||
|
||||
return loadUpdateServerConfigFromPath(updateServerConfigPath)
|
||||
}
|
||||
|
||||
func loadUpdateServerConfigFromPath(path string) (UpdateServerConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return defaultUpdateServerConfig(), nil
|
||||
}
|
||||
return UpdateServerConfig{}, err
|
||||
}
|
||||
|
||||
var cfg UpdateServerConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return UpdateServerConfig{}, fmt.Errorf("decode update server config: %w", err)
|
||||
}
|
||||
|
||||
normalizedURL, err := normalizeUpdateServerURL(cfg.URL)
|
||||
if err != nil {
|
||||
return UpdateServerConfig{}, err
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
normalizedURL = StableURL
|
||||
}
|
||||
cfg.URL = normalizedURL
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveUpdateServerConfig(cfg UpdateServerConfig) error {
|
||||
updateServerConfigMu.Lock()
|
||||
defer updateServerConfigMu.Unlock()
|
||||
|
||||
return saveUpdateServerConfigToPath(updateServerConfigPath, cfg)
|
||||
}
|
||||
|
||||
func saveUpdateServerConfigToPath(path string, cfg UpdateServerConfig) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create update server config directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode update server config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".application-update.json.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary update server config: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set update server config permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write update server config: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync update server config: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close update server config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("replace update server config: %w", err)
|
||||
}
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
_ = directory.Sync()
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,10 +23,20 @@ type Latest struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Sha512 string `json:"sha512"`
|
||||
Size uint `json:"size"`
|
||||
Url string `json:"url"`
|
||||
Size uint64 `json:"size"`
|
||||
Url string `json:"-"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxLatestJSONSize = 64 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
latestClient = &http.Client{Timeout: 15 * time.Second}
|
||||
packageNamePattern = regexp.MustCompile(`^nanokvm_[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz$`)
|
||||
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
)
|
||||
|
||||
func (s *Service) GetVersion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -39,9 +53,12 @@ func (s *Service) GetVersion(c *gin.Context) {
|
||||
// latest version
|
||||
latestVersion := ""
|
||||
latest, err := getLatest()
|
||||
if err == nil && latest != nil {
|
||||
latestVersion = latest.Version
|
||||
if err != nil {
|
||||
log.Errorf("failed to get latest version: %s", err)
|
||||
rsp.ErrRsp(c, -1, "failed to query latest version")
|
||||
return
|
||||
}
|
||||
latestVersion = latest.Version
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVersionRsp{
|
||||
Current: currentVersion,
|
||||
@@ -50,23 +67,33 @@ func (s *Service) GetVersion(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getLatest() (*Latest, error) {
|
||||
baseURL := StableURL
|
||||
if isPreviewEnabled() {
|
||||
baseURL = PreviewURL
|
||||
baseURL, err := resolveUpdateBaseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/latest.json?now=%d", baseURL, time.Now().Unix())
|
||||
|
||||
resp, err := http.Get(url)
|
||||
manifestURL, err := joinUpdateURL(baseURL, "latest.json")
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
parsedManifestURL, err := url.Parse(manifestURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := parsedManifestURL.Query()
|
||||
query.Set("now", fmt.Sprintf("%d", time.Now().Unix()))
|
||||
parsedManifestURL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := latestClient.Get(parsedManifestURL.String())
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version from %s", parsedManifestURL.Redacted())
|
||||
return nil, errors.New("update server is inaccessible")
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLatestJSONSize+1))
|
||||
if err != nil {
|
||||
log.Errorf("failed to read response: %v", err)
|
||||
return nil, err
|
||||
@@ -76,15 +103,49 @@ func getLatest() (*Latest, error) {
|
||||
log.Errorf("server responded with status code: %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
if len(body) > maxLatestJSONSize {
|
||||
return nil, fmt.Errorf("latest manifest exceeds %d bytes", maxLatestJSONSize)
|
||||
}
|
||||
|
||||
var latest Latest
|
||||
if err := json.Unmarshal(body, &latest); err != nil {
|
||||
log.Errorf("failed to unmarshal response: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLatest(&latest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
latest.Url = fmt.Sprintf("%s/%s", baseURL, latest.Name)
|
||||
latest.Url, err = joinUpdateURL(baseURL, latest.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("get application latest version: %s", latest.Version)
|
||||
return &latest, nil
|
||||
}
|
||||
|
||||
func joinUpdateURL(baseURL string, element string) (string, error) {
|
||||
joined, err := url.JoinPath(baseURL, element)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("join update URL: %w", err)
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
func validateLatest(latest *Latest) error {
|
||||
if !versionPattern.MatchString(latest.Version) {
|
||||
return errors.New("invalid latest version")
|
||||
}
|
||||
if !packageNamePattern.MatchString(latest.Name) {
|
||||
return errors.New("invalid update package name")
|
||||
}
|
||||
digest, err := base64.StdEncoding.DecodeString(latest.Sha512)
|
||||
if err != nil || len(digest) != 64 {
|
||||
return errors.New("invalid update package sha512")
|
||||
}
|
||||
if latest.Size == 0 {
|
||||
return errors.New("invalid update package size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,16 +2,22 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const maxDownloadSize = int64(1024 * 1024 * 1024)
|
||||
|
||||
var downloadClient = &http.Client{Timeout: 15 * time.Minute}
|
||||
|
||||
func Download(req *http.Request, target string) error {
|
||||
log.Debugf("downloading %s to %s", req.URL.String(), target)
|
||||
log.Debugf("downloading %s to %s", req.URL.Redacted(), target)
|
||||
err := os.MkdirAll(filepath.Dir(target), 0o755)
|
||||
if err != nil {
|
||||
log.Errorf("create dir %s err: %s", filepath.Dir(target), err)
|
||||
@@ -26,10 +32,10 @@ func Download(req *http.Request, target string) error {
|
||||
_ = out.Close()
|
||||
}()
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
resp, err := downloadClient.Do(req)
|
||||
if err != nil {
|
||||
log.Errorf("request error: %s", err)
|
||||
return err
|
||||
log.Errorf("request to %s failed", req.URL.Redacted())
|
||||
return errors.New("update website is inaccessible right now")
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
@@ -46,11 +52,14 @@ func Download(req *http.Request, target string) error {
|
||||
return errors.New("unsupported content type")
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
written, err := io.Copy(out, io.LimitReader(resp.Body, maxDownloadSize+1))
|
||||
if err != nil {
|
||||
log.Errorf("download file to %s err: %s", target, err)
|
||||
return err
|
||||
}
|
||||
if written > maxDownloadSize {
|
||||
return fmt.Errorf("download exceeds %d bytes", maxDownloadSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user