You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
502 lines
18 KiB
502 lines
18 KiB
package update
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"tera-clone/services/tera-api/internal/storage/atomicfile"
|
|
"tera-clone/services/tera-api/internal/storage/capacity"
|
|
)
|
|
|
|
var safeFilename = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
type ManagerOptions struct {
|
|
UpdateRoot string
|
|
PolicyPath string
|
|
TrustDirectory string
|
|
Product string
|
|
Profile string
|
|
CurrentVersion string
|
|
Clock func() time.Time
|
|
RecoveryApplyEnabled bool
|
|
RequestRecovery func(string) error
|
|
Reboot func() error
|
|
}
|
|
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
active bool
|
|
status Status
|
|
root string
|
|
incoming string
|
|
validated string
|
|
journalPath string
|
|
trustDirectory string
|
|
product string
|
|
profile string
|
|
currentVersion string
|
|
clock func() time.Time
|
|
governor *capacity.Governor
|
|
recoveryApplyEnabled bool
|
|
requestRecovery func(string) error
|
|
reboot func() error
|
|
}
|
|
|
|
func NewManager(options ManagerOptions) (*Manager, error) {
|
|
if options.Clock == nil {
|
|
options.Clock = time.Now
|
|
}
|
|
if options.Product == "" || options.Profile == "" || options.CurrentVersion == "" || options.TrustDirectory == "" {
|
|
return nil, errors.New("update product, profile, current version, and trust directory are required")
|
|
}
|
|
if options.RecoveryApplyEnabled && (options.RequestRecovery == nil || options.Reboot == nil) {
|
|
return nil, errors.New("enabled recovery apply requires recovery-request and reboot adapters")
|
|
}
|
|
root := filepath.Clean(options.UpdateRoot)
|
|
directories := []string{root, filepath.Join(root, "incoming"), filepath.Join(root, "validated"), filepath.Join(root, "journal")}
|
|
for _, directory := range directories {
|
|
if err := requireDirectory(directory); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := requireDirectory(options.TrustDirectory); err != nil {
|
|
return nil, err
|
|
}
|
|
policy, err := capacity.LoadPolicy(options.PolicyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
governor, err := capacity.New(root, policy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manager := &Manager{
|
|
root: root, incoming: directories[1], validated: directories[2], journalPath: filepath.Join(directories[3], "status.json"),
|
|
trustDirectory: options.TrustDirectory, product: options.Product, profile: options.Profile, currentVersion: options.CurrentVersion,
|
|
clock: options.Clock, governor: governor, recoveryApplyEnabled: options.RecoveryApplyEnabled,
|
|
requestRecovery: options.RequestRecovery, reboot: options.Reboot,
|
|
}
|
|
if err := manager.recover(); err != nil {
|
|
return nil, err
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
func (m *Manager) Status(context.Context) (Status, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
status := cloneStatus(m.status)
|
|
snapshot, err := m.governor.Snapshot()
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
status.Capacity = snapshot
|
|
return status, nil
|
|
}
|
|
|
|
func (m *Manager) Upload(ctx context.Context, request UploadRequest) (Status, error) {
|
|
filename := strings.TrimSpace(request.Filename)
|
|
if request.Body == nil || request.ContentLength <= 0 || !safeFilename.MatchString(filename) || filepath.Base(filename) != filename {
|
|
return Status{}, &Error{Code: "update_validation_failed", Message: "a bounded local package filename and content length are required"}
|
|
}
|
|
m.mu.Lock()
|
|
if m.active {
|
|
m.mu.Unlock()
|
|
return Status{}, &Error{Code: "update_conflict", Message: "another firmware upload is already active"}
|
|
}
|
|
m.active = true
|
|
m.mu.Unlock()
|
|
defer func() {
|
|
m.mu.Lock()
|
|
m.active = false
|
|
m.mu.Unlock()
|
|
}()
|
|
|
|
reservation, snapshot, err := m.governor.Reserve(request.ContentLength)
|
|
if err != nil {
|
|
code := "update_capacity_failed"
|
|
if !errors.Is(err, capacity.ErrInsufficientSpace) {
|
|
code = "update_unavailable"
|
|
}
|
|
return Status{}, &Error{Code: code, Message: err.Error()}
|
|
}
|
|
defer reservation.Release()
|
|
|
|
operationID, err := randomID()
|
|
if err != nil {
|
|
return Status{}, &Error{Code: "update_unavailable", Message: "cannot allocate update operation id"}
|
|
}
|
|
failUpload := func(code, reason string, cleanupPaths ...string) (Status, error) {
|
|
reservation.Release()
|
|
return m.fail(operationID, code, reason, cleanupPaths...)
|
|
}
|
|
now := m.clock().UTC()
|
|
status := Status{
|
|
FormatVersion: 1, OperationID: operationID, State: StateReceiving, Message: "Receiving local firmware package",
|
|
Filename: filename, DeviceVersion: m.currentVersion, BytesTotal: request.ContentLength, ApplyEnabled: false,
|
|
ApplyDisabledReason: "Package validation has not completed", RecoveryInstructions: "If validation fails, correct the package and upload again. No running firmware is changed.",
|
|
Capacity: snapshot, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return Status{}, &Error{Code: "update_unavailable", Message: err.Error()}
|
|
}
|
|
partPath := filepath.Join(m.incoming, operationID+".part")
|
|
file, err := os.OpenFile(partPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return failUpload("update_staging_failed", "Cannot create the protected staging file", partPath)
|
|
}
|
|
buffer := make([]byte, 1024*1024)
|
|
reader := &contextReader{ctx: ctx, reader: io.LimitReader(request.Body, request.ContentLength+1)}
|
|
progress := &progressWriter{writer: file, onProgress: func(received int64) { m.updateProgress(received) }}
|
|
written, copyErr := io.CopyBuffer(progress, reader, buffer)
|
|
syncErr := file.Sync()
|
|
closeErr := file.Close()
|
|
if copyErr != nil || syncErr != nil || closeErr != nil || written != request.ContentLength {
|
|
return failUpload("update_receive_failed", "Firmware upload was interrupted or its length did not match", partPath)
|
|
}
|
|
stagedPath := filepath.Join(m.incoming, operationID+".pkg")
|
|
if err := os.Rename(partPath, stagedPath); err != nil || syncDirectory(m.incoming) != nil {
|
|
return failUpload("update_staging_failed", "Cannot durably stage the firmware package", partPath, stagedPath)
|
|
}
|
|
status = m.transition(StateStaged, "Firmware package staged", 80)
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return failUpload("update_journal_failed", "Cannot persist the staged state", stagedPath)
|
|
}
|
|
status = m.transition(StateValidating, "Validating signature, compatibility, version, size, and payload digests", 85)
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return failUpload("update_journal_failed", "Cannot persist the validation state", stagedPath)
|
|
}
|
|
manifest, signerKeyID, err := VerifyPackage(stagedPath, m.trustDirectory, m.product, m.profile, m.currentVersion)
|
|
if err != nil {
|
|
return failUpload("update_validation_failed", err.Error(), stagedPath)
|
|
}
|
|
validatedPath := filepath.Join(m.validated, operationID+".pkg")
|
|
if err := os.Rename(stagedPath, validatedPath); err != nil || syncDirectory(m.incoming) != nil || syncDirectory(m.validated) != nil {
|
|
return failUpload("update_staging_failed", "Cannot commit the validated package", stagedPath, validatedPath)
|
|
}
|
|
status = m.transition(StateValidated, "Package is authentic and compatible; running firmware was not changed", 100)
|
|
status.Manifest = &manifest
|
|
status.SignerKeyID = signerKeyID
|
|
status.PackagePath = validatedPath
|
|
status.ApplyEnabled, status.ApplyDisabledReason = m.applyPolicy(manifest)
|
|
status.RecoveryInstructions = manifest.Recovery.Instructions
|
|
reservation.Release()
|
|
if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil {
|
|
status.Capacity = snapshot
|
|
}
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return failUpload("update_journal_failed", "Validated package exists but its journal could not be committed", validatedPath)
|
|
}
|
|
// Keep the previously validated package until the new validated state is
|
|
// durable. Cleanup after the journal commit preserves an atomic fallback if
|
|
// the final status write fails.
|
|
m.removeOlderValidated(validatedPath)
|
|
if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil {
|
|
status.Capacity = snapshot
|
|
_ = m.setStatus(status, false)
|
|
}
|
|
return cloneStatus(status), nil
|
|
}
|
|
|
|
func (m *Manager) Apply(ctx context.Context, request ApplyRequest) (Status, error) {
|
|
if strings.TrimSpace(request.Actor) == "" {
|
|
return Status{}, &Error{Code: "update_validation_failed", Message: "an authenticated update actor is required"}
|
|
}
|
|
m.mu.Lock()
|
|
if m.active {
|
|
m.mu.Unlock()
|
|
return Status{}, &Error{Code: "update_conflict", Message: "another firmware operation is already active"}
|
|
}
|
|
status := cloneStatus(m.status)
|
|
m.active = true
|
|
m.mu.Unlock()
|
|
defer func() {
|
|
m.mu.Lock()
|
|
m.active = false
|
|
m.mu.Unlock()
|
|
}()
|
|
if status.State != StateValidated || status.Manifest == nil {
|
|
return status, &Error{Code: "update_apply_unsupported", Message: "a signed recovery package must be validated before apply"}
|
|
}
|
|
if request.OperationID == "" || request.OperationID != status.OperationID {
|
|
return status, &Error{Code: "update_validation_failed", Message: "apply operation id does not match the validated package"}
|
|
}
|
|
enabled, reason := m.applyPolicy(*status.Manifest)
|
|
if !enabled {
|
|
status.ApplyEnabled, status.ApplyDisabledReason = false, reason
|
|
return status, &Error{Code: "update_apply_unsupported", Message: reason}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return status, &Error{Code: "update_interrupted", Message: "apply request was cancelled before recovery was armed"}
|
|
default:
|
|
}
|
|
packagePath := filepath.Join(m.validated, status.OperationID+".pkg")
|
|
manifest, signer, err := VerifyPackage(packagePath, m.trustDirectory, m.product, m.profile, m.currentVersion)
|
|
if err != nil || manifest.PackageID != status.Manifest.PackageID || signer != status.SignerKeyID {
|
|
reason := "validated package identity changed before apply"
|
|
if err != nil {
|
|
reason = err.Error()
|
|
}
|
|
return m.fail(status.OperationID, "update_revalidation_failed", reason)
|
|
}
|
|
status.State = StateApplyPending
|
|
status.Message = "Recovery apply accepted; synchronizing durable request state"
|
|
status.ProgressPercent = 100
|
|
status.ApplyEnabled = false
|
|
status.ApplyDisabledReason = "Recovery apply is already pending"
|
|
status.RecoveryStep = "request-pending"
|
|
status.UpdatedAt = m.clock().UTC()
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return status, &Error{Code: "update_journal_failed", Message: err.Error()}
|
|
}
|
|
if err := m.requestRecovery(status.OperationID); err != nil {
|
|
return m.fail(status.OperationID, "update_recovery_request_failed", err.Error())
|
|
}
|
|
status.State = StateRebootPending
|
|
status.Message = "Recovery request is durable; rebooting into the offline updater"
|
|
status.RecoveryStep = "reboot-pending"
|
|
status.UpdatedAt = m.clock().UTC()
|
|
if err := m.setStatus(status, true); err != nil {
|
|
return status, &Error{Code: "update_journal_failed", Message: err.Error()}
|
|
}
|
|
if err := m.reboot(); err != nil {
|
|
status.RecoveryInstructions = "The recovery request is already set. Use the approved service procedure to reset the device; do not clear misc or remove the validated package."
|
|
_ = m.setStatus(status, true)
|
|
return cloneStatus(status), &Error{Code: "update_reboot_failed", Message: err.Error()}
|
|
}
|
|
return cloneStatus(status), nil
|
|
}
|
|
|
|
func (m *Manager) applyPolicy(manifest Manifest) (bool, string) {
|
|
if manifest.Apply.Capability != ApplyCapabilityRecoveryV1 {
|
|
return false, "This package is validation-only and cannot change running firmware"
|
|
}
|
|
if !m.recoveryApplyEnabled {
|
|
return false, "Recovery Apply remains locked until the target recovery boot, readback, fault-injection, and service-path gates are approved"
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func (m *Manager) recover() error {
|
|
entries, err := os.ReadDir(m.incoming)
|
|
if err != nil {
|
|
return fmt.Errorf("scan update incoming directory: %w", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.Type().IsRegular() && (strings.HasSuffix(entry.Name(), ".part") || strings.HasSuffix(entry.Name(), ".pkg")) {
|
|
if err := os.Remove(filepath.Join(m.incoming, entry.Name())); err != nil {
|
|
return fmt.Errorf("remove interrupted update staging: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if data, err := os.ReadFile(m.journalPath); err == nil {
|
|
if err := decodeSingleJSON(data, &m.status); err != nil {
|
|
return fmt.Errorf("decode update journal: %w", err)
|
|
}
|
|
if m.status.State == StateReceiving || m.status.State == StateStaged || m.status.State == StateValidating {
|
|
m.status.State = StateFailed
|
|
m.status.Message = "Interrupted update operation recovered"
|
|
m.status.FailureCode = "update_interrupted"
|
|
m.status.FailureReason = "The updater restarted before validation completed; the partial package was removed"
|
|
m.status.RecoveryInstructions = "Upload the signed local package again. The running firmware was not changed."
|
|
m.status.UpdatedAt = m.clock().UTC()
|
|
return m.persistLocked()
|
|
}
|
|
if m.status.State == StateValidated && m.status.Manifest != nil {
|
|
if m.status.DeviceVersion == "" {
|
|
m.status.DeviceVersion = m.currentVersion
|
|
}
|
|
m.status.ApplyEnabled, m.status.ApplyDisabledReason = m.applyPolicy(*m.status.Manifest)
|
|
return m.persistLocked()
|
|
}
|
|
return nil
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("read update journal: %w", err)
|
|
}
|
|
now := m.clock().UTC()
|
|
m.status = Status{FormatVersion: 1, State: StateIdle, Message: "No firmware package has been uploaded", DeviceVersion: m.currentVersion, ApplyEnabled: false, ApplyDisabledReason: "Upload and validate a signed local package first", RecoveryInstructions: "No recovery action is required.", UpdatedAt: now}
|
|
return m.persistLocked()
|
|
}
|
|
|
|
func (m *Manager) fail(operationID, code, reason string, cleanupPaths ...string) (Status, error) {
|
|
for _, cleanupPath := range cleanupPaths {
|
|
if cleanupPath != "" {
|
|
_ = os.Remove(cleanupPath)
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
status := m.status
|
|
m.mu.Unlock()
|
|
status.OperationID = operationID
|
|
status.State = StateFailed
|
|
status.Message = "Firmware package validation did not complete"
|
|
status.ProgressPercent = 0
|
|
status.ApplyEnabled = false
|
|
status.ApplyDisabledReason = "Only a successfully validated package can proceed"
|
|
status.FailureCode = code
|
|
status.FailureReason = reason
|
|
status.RecoveryInstructions = "Correct or replace the signed package and upload it again. The running firmware was not changed."
|
|
status.UpdatedAt = m.clock().UTC()
|
|
if snapshot, snapshotErr := m.governor.Snapshot(); snapshotErr == nil {
|
|
status.Capacity = snapshot
|
|
}
|
|
_ = m.setStatus(status, true)
|
|
return cloneStatus(status), &Error{Code: code, Message: reason}
|
|
}
|
|
|
|
func (m *Manager) transition(state, message string, progress int) Status {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
status := cloneStatus(m.status)
|
|
status.State, status.Message, status.ProgressPercent, status.UpdatedAt = state, message, progress, m.clock().UTC()
|
|
return status
|
|
}
|
|
|
|
func (m *Manager) updateProgress(received int64) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.status.BytesReceived = received
|
|
if m.status.BytesTotal > 0 {
|
|
m.status.ProgressPercent = int(received * 75 / m.status.BytesTotal)
|
|
}
|
|
m.status.UpdatedAt = m.clock().UTC()
|
|
}
|
|
|
|
func (m *Manager) setStatus(status Status, persist bool) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.status = cloneStatus(status)
|
|
if persist {
|
|
return m.persistLocked()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) persistLocked() error {
|
|
data, err := json.MarshalIndent(m.status, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode update journal: %w", err)
|
|
}
|
|
if err := atomicfile.Write(m.journalPath, append(data, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("persist update journal: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) removeOlderValidated(keep string) {
|
|
entries, err := os.ReadDir(m.validated)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
candidate := filepath.Join(m.validated, entry.Name())
|
|
if candidate != keep && entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".pkg") {
|
|
_ = os.Remove(candidate)
|
|
}
|
|
}
|
|
}
|
|
|
|
func requireDirectory(path string) error {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect required directory %s: %w", path, err)
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("required path is not a safe directory: %s", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomID() (string, error) {
|
|
value := make([]byte, 16)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return "fw-" + hex.EncodeToString(value), nil
|
|
}
|
|
|
|
func cloneStatus(value Status) Status {
|
|
if value.Manifest != nil {
|
|
manifest := *value.Manifest
|
|
manifest.Payloads = append([]Payload(nil), value.Manifest.Payloads...)
|
|
value.Manifest = &manifest
|
|
}
|
|
return value
|
|
}
|
|
|
|
type contextReader struct {
|
|
ctx context.Context
|
|
reader io.Reader
|
|
}
|
|
|
|
func (r *contextReader) Read(buffer []byte) (int, error) {
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return 0, r.ctx.Err()
|
|
default:
|
|
return r.reader.Read(buffer)
|
|
}
|
|
}
|
|
|
|
type progressWriter struct {
|
|
writer io.Writer
|
|
received int64
|
|
onProgress func(int64)
|
|
}
|
|
|
|
func (w *progressWriter) Write(buffer []byte) (int, error) {
|
|
n, err := w.writer.Write(buffer)
|
|
w.received += int64(n)
|
|
if w.onProgress != nil {
|
|
w.onProgress(w.received)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// FactoryReset removes uploaded packages and their journal, not installed
|
|
// firmware or the firmware-signing trust anchors.
|
|
func (m *Manager) FactoryReset(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if m.active || m.status.State == StateRebootPending || m.status.State == StateApplying {
|
|
return errors.New("cannot factory reset during firmware update")
|
|
}
|
|
for _, dir := range []string{m.incoming, m.validated, filepath.Dir(m.journalPath)} {
|
|
if err := requireDirectory(dir); err != nil {
|
|
return err
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
path := filepath.Join(dir, entry.Name())
|
|
if filepath.Dir(path) != dir || entry.IsDir() {
|
|
return errors.New("unexpected firmware staging entry")
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := syncDirectory(dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
m.status = Status{FormatVersion: 1, State: StateIdle, Message: "No firmware package has been uploaded", DeviceVersion: m.currentVersion, UpdatedAt: m.clock().UTC()}
|
|
return m.persistLocked()
|
|
}
|
|
|