package ota import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptrace" "net/url" "time" "github.com/rs/zerolog" ) // HttpClient is the interface for the HTTP client type HttpClient interface { Do(req *http.Request) (*http.Response, error) } // UpdateReleaseAPIEndpoint updates the release API endpoint func (s *State) UpdateReleaseAPIEndpoint(endpoint string) { s.releaseAPIEndpoint = endpoint } // GetReleaseAPIEndpoint returns the release API endpoint func (s *State) GetReleaseAPIEndpoint() string { return s.releaseAPIEndpoint } // getUpdateURL returns the update URL for the given parameters func (s *State) getUpdateURL(params UpdateParams) (string, error, bool) { updateURL, err := url.Parse(s.releaseAPIEndpoint) if err != nil { return "", fmt.Errorf("error parsing update metadata URL: %w", err), false } isCustomVersion := false query := updateURL.Query() query.Set("deviceId", params.DeviceID) query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease)) // set the custom versions if they are specified for component, constraint := range params.Components { if constraint == "" { continue } query.Set(component+"Version", constraint) isCustomVersion = true } updateURL.RawQuery = query.Encode() return updateURL.String(), nil, isCustomVersion } // newHTTPRequestWithTrace creates a new HTTP request with a trace logger // TODO: use OTEL instead of doing this manually func (s *State) newHTTPRequestWithTrace(ctx context.Context, method, url string, body io.Reader, logger func() *zerolog.Event) (*http.Request, error) { localCtx := ctx if s.l.GetLevel() <= zerolog.TraceLevel { if logger == nil { logger = func() *zerolog.Event { return s.l.Trace() } } l := func() *zerolog.Event { return logger().Str("url", url).Str("method", method) } localCtx = httptrace.WithClientTrace(localCtx, &httptrace.ClientTrace{ GetConn: func(hostPort string) { l().Str("hostPort", hostPort).Msg("[conn] starting to create conn") }, GotConn: func(info httptrace.GotConnInfo) { l().Interface("info", info).Msg("[conn] connection established") }, PutIdleConn: func(err error) { l().Err(err).Msg("[conn] connection returned to idle pool") }, GotFirstResponseByte: func() { l().Msg("[resp] first response byte received") }, Got100Continue: func() { l().Msg("[resp] 100 continue received") }, DNSStart: func(info httptrace.DNSStartInfo) { l().Interface("info", info).Msg("[dns] starting to look up dns") }, DNSDone: func(info httptrace.DNSDoneInfo) { l().Interface("info", info).Msg("[dns] done looking up dns") }, ConnectStart: func(network, addr string) { l().Str("network", network).Str("addr", addr).Msg("[tcp] starting tcp connection") }, ConnectDone: func(network, addr string, err error) { l().Str("network", network).Str("addr", addr).Err(err).Msg("[tcp] tcp connection created") }, TLSHandshakeStart: func() { l().Msg("[tls] handshake started") }, TLSHandshakeDone: func(state tls.ConnectionState, err error) { l(). Str("tlsVersion", tls.VersionName(state.Version)). Str("cipherSuite", tls.CipherSuiteName(state.CipherSuite)). Str("negotiatedProtocol", state.NegotiatedProtocol). Str("serverName", state.ServerName). Err(err).Msg("[tls] handshake done") }, }) } return http.NewRequestWithContext(localCtx, method, url, body) } func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) { metadata := &UpdateMetadata{} logger := s.l.With().Logger() if params.RequestID != "" { logger = logger.With().Str("requestID", params.RequestID).Logger() } t := time.Now() traceLogger := func() *zerolog.Event { return logger.Trace().Dur("duration", time.Since(t)) } url, err, isCustomVersion := s.getUpdateURL(params) traceLogger().Err(err). Msg("fetchUpdateMetadata: getUpdateURL") if err != nil { return nil, fmt.Errorf("error getting update URL: %w", err) } traceLogger(). Str("url", url). Msg("fetching update metadata") req, err := s.newHTTPRequestWithTrace(ctx, "GET", url, nil, traceLogger) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } client := s.client() resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error sending request: %w", err) } defer resp.Body.Close() traceLogger(). Int("status", resp.StatusCode). Msg("fetchUpdateMetadata: response") if isCustomVersion && resp.StatusCode == http.StatusNotFound { return nil, ErrVersionNotFound } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } err = json.NewDecoder(resp.Body).Decode(metadata) if err != nil { return nil, fmt.Errorf("error decoding response: %w", err) } traceLogger(). Msg("fetchUpdateMetadata: completed") return metadata, nil } func (s *State) triggerStateUpdate() { s.onStateUpdate(s.ToRPCState()) } func (s *State) triggerComponentUpdateState(component string, update *componentUpdateStatus) { s.componentUpdateStatuses[component] = *update s.triggerStateUpdate() } // TryUpdate tries to update the given components // if the update is already in progress, it returns an error func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error { locked := s.mu.TryLock() if !locked { return fmt.Errorf("update already in progress") } return s.doUpdate(ctx, params) } // before calling doUpdate, the caller must have locked the mutex // otherwise a runtime error will occur func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { defer s.mu.Unlock() scopedLogger := s.l.With(). Interface("params", params). Logger() scopedLogger.Info().Msg("checking for updates") if s.updating { return fmt.Errorf("update already in progress") } s.updating = true s.triggerStateUpdate() if len(params.Components) == 0 { params.Components = defaultComponents } _, shouldUpdateApp := params.Components["app"] _, shouldUpdateSystem := params.Components["system"] if !shouldUpdateApp && !shouldUpdateSystem { return s.componentUpdateError( "Update aborted: no components were specified to update. Requested components: ", fmt.Errorf("%v", params.Components), &scopedLogger, ) } appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) if err != nil { return s.componentUpdateError("Error checking for updates", err, &scopedLogger) } s.metadataFetchedAt = time.Now() s.triggerStateUpdate() if shouldUpdateApp && appUpdate.available { appUpdate.pending = true s.updating = true s.triggerComponentUpdateState("app", appUpdate) } if shouldUpdateSystem && systemUpdate.available { systemUpdate.pending = true s.updating = true s.triggerComponentUpdateState("system", systemUpdate) } if !appUpdate.pending && !systemUpdate.pending { scopedLogger.Info().Msg("No updates available") s.updating = false s.triggerStateUpdate() return nil } scopedLogger.Trace().Bool("pending", appUpdate.pending).Msg("Checking for app update") if appUpdate.pending { scopedLogger.Info(). Str("url", appUpdate.url). Str("hash", appUpdate.hash). Msg("App update available") if err := s.updateApp(ctx, appUpdate); err != nil { return s.componentUpdateError("Error updating app", err, &scopedLogger) } } else { scopedLogger.Info().Msg("App is up to date") } scopedLogger.Trace().Bool("pending", systemUpdate.pending).Msg("Checking for system update") if systemUpdate.pending { if err := s.updateSystem(ctx, systemUpdate); err != nil { return s.componentUpdateError("Error updating system", err, &scopedLogger) } } else { scopedLogger.Info().Msg("System is up to date") } if s.rebootNeeded { if appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate { scopedLogger.Info().Msg("disabling auto-update due to custom version update") // If they are explicitly updating a custom version, we assume they want to disable auto-update if _, err := s.setAutoUpdate(false); err != nil { scopedLogger.Warn().Err(err).Msg("Failed to disable auto-update") } } scopedLogger.Info().Msg("System Rebooting due to OTA update") redirectUrl := "/settings/general/update" if params.ResetConfig { scopedLogger.Info().Msg("Resetting config") if err := s.resetConfig(); err != nil { return s.componentUpdateError("Error resetting config", err, &scopedLogger) } redirectUrl = "/welcome" } postRebootAction := &PostRebootAction{ HealthCheck: "/device/status", RedirectTo: redirectUrl, } // REBOOT_REDIRECT_DELAY_MS is 7 seconds in the UI, // it means that healthCheckUrl will be called after 7 seconds that we send willReboot JSONRPC event // so we need to reboot it within 7 seconds to avoid it being called before the device is rebooted if err := s.reboot(true, postRebootAction, 5*time.Second); err != nil { return s.componentUpdateError("Error requesting reboot", err, &scopedLogger) } } // We don't need set the updating flag to false here. Either it will; // - set to false by the componentUpdateError function // - device will reboot return nil } // UpdateParams represents the parameters for the update type UpdateParams struct { DeviceID string `json:"deviceID"` Components map[string]string `json:"components"` IncludePreRelease bool `json:"includePreRelease"` ResetConfig bool `json:"resetConfig"` // RequestID is a unique identifier for the update request // When it's set, detailed trace logs will be enabled (if the log level is Trace) RequestID string } // getUpdateStatus gets the update status for the given components // and updates the componentUpdateStatuses map func (s *State) getUpdateStatus( ctx context.Context, params UpdateParams, ) ( appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, err error, ) { appUpdate = &componentUpdateStatus{} systemUpdate = &componentUpdateStatus{} if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok { appUpdate = ¤tAppUpdate } if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok { systemUpdate = ¤tSystemUpdate } err = s.checkUpdateStatus(ctx, params, appUpdate, systemUpdate) if err != nil { return nil, nil, err } s.componentUpdateStatuses["app"] = *appUpdate s.componentUpdateStatuses["system"] = *systemUpdate return appUpdate, systemUpdate, nil } // checkUpdateStatus checks the update status for the given components func (s *State) checkUpdateStatus( ctx context.Context, params UpdateParams, appUpdateStatus *componentUpdateStatus, systemUpdateStatus *componentUpdateStatus, ) error { // get the local versions systemVersionLocal, appVersionLocal, err := s.getLocalVersion() if err != nil { return fmt.Errorf("error getting local version: %w", err) } appUpdateStatus.localVersion = appVersionLocal.String() systemUpdateStatus.localVersion = systemVersionLocal.String() logger := s.l.With().Logger() if params.RequestID != "" { logger = logger.With().Str("requestID", params.RequestID).Logger() } t := time.Now() logger.Trace(). Str("appVersionLocal", appVersionLocal.String()). Str("systemVersionLocal", systemVersionLocal.String()). Dur("duration", time.Since(t)). Msg("checkUpdateStatus: getLocalVersion") // fetch the remote metadata remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) if err != nil { if err == ErrVersionNotFound || errors.Unwrap(err) == ErrVersionNotFound { err = ErrVersionNotFound } else { err = fmt.Errorf("error checking for updates: %w", err) } return err } logger.Trace(). Interface("remoteMetadata", remoteMetadata). Dur("duration", time.Since(t)). Msg("checkUpdateStatus: fetchUpdateMetadata") // parse the remote metadata to the componentUpdateStatuses if err := remoteMetadataToComponentStatus( remoteMetadata, "app", appUpdateStatus, params, ); err != nil { return fmt.Errorf("error parsing remote app version: %w", err) } if err := remoteMetadataToComponentStatus( remoteMetadata, "system", systemUpdateStatus, params, ); err != nil { return fmt.Errorf("error parsing remote system version: %w", err) } if s.l.GetLevel() <= zerolog.TraceLevel { appUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [app]") systemUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [system]") } logger.Trace(). Dur("duration", time.Since(t)). Msg("checkUpdateStatus: completed") return nil } // GetUpdateStatus returns the current update status (for backwards compatibility) func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { // if no components are specified, use the default components // we should remove this once app router feature is released if len(params.Components) == 0 { params.Components = defaultComponents } appUpdateStatus := componentUpdateStatus{} systemUpdateStatus := componentUpdateStatus{} err := s.checkUpdateStatus(ctx, params, &appUpdateStatus, &systemUpdateStatus) if err != nil { return nil, fmt.Errorf("error getting update status: %w", err) } return toUpdateStatus(&appUpdateStatus, &systemUpdateStatus, ""), nil }