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

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()
}