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.
386 lines
13 KiB
386 lines
13 KiB
|
2 months ago
|
// Package recoveryapply implements the offline, resumable non-A/B partition
|
||
|
|
// writer. It is used only by the authenticated recovery initramfs.
|
||
|
|
package recoveryapply
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"tera-clone/services/tera-api/internal/storage/atomicfile"
|
||
|
|
"tera-clone/services/tera-api/internal/update"
|
||
|
|
)
|
||
|
|
|
||
|
|
const JournalFormatVersion = 1
|
||
|
|
|
||
|
|
// The recovery updater runs as root, while tera-updated reads status.json as
|
||
|
|
// its unprivileged service account after the normal system boots. The parent
|
||
|
|
// journal directory remains 0750 and service-owned, so 0644 makes the durable
|
||
|
|
// hand-off readable without exposing it outside that protected directory.
|
||
|
|
const recoveryStatusMode = 0o644
|
||
|
|
|
||
|
|
type Target struct {
|
||
|
|
Path string
|
||
|
|
MaxBytes int64
|
||
|
|
}
|
||
|
|
|
||
|
|
type Transition struct {
|
||
|
|
Phase string
|
||
|
|
PayloadIndex int
|
||
|
|
PayloadName string
|
||
|
|
TargetPartition string
|
||
|
|
}
|
||
|
|
|
||
|
|
type Options struct {
|
||
|
|
OperationID string
|
||
|
|
PackagePath string
|
||
|
|
JournalPath string
|
||
|
|
StatusPath string
|
||
|
|
TrustDirectory string
|
||
|
|
Product string
|
||
|
|
Profile string
|
||
|
|
CurrentVersion string
|
||
|
|
Targets map[string]Target
|
||
|
|
AllowRegularTarget bool
|
||
|
|
ClearRecovery func() error
|
||
|
|
Clock func() time.Time
|
||
|
|
AfterTransition func(Transition) error
|
||
|
|
}
|
||
|
|
|
||
|
|
type Journal struct {
|
||
|
|
FormatVersion int `json:"format_version"`
|
||
|
|
OperationID string `json:"operation_id"`
|
||
|
|
PackageID string `json:"package_id"`
|
||
|
|
Phase string `json:"phase"`
|
||
|
|
PayloadIndex int `json:"payload_index"`
|
||
|
|
PayloadName string `json:"payload_name,omitempty"`
|
||
|
|
TargetPartition string `json:"target_partition,omitempty"`
|
||
|
|
Attempt int `json:"attempt"`
|
||
|
|
LastError string `json:"last_error,omitempty"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Engine struct{ options Options }
|
||
|
|
|
||
|
|
func New(options Options) (*Engine, error) {
|
||
|
|
if options.Clock == nil {
|
||
|
|
options.Clock = time.Now
|
||
|
|
}
|
||
|
|
if options.OperationID == "" || options.PackagePath == "" || options.JournalPath == "" || options.StatusPath == "" ||
|
||
|
|
options.TrustDirectory == "" || options.Product == "" || options.Profile == "" || options.CurrentVersion == "" ||
|
||
|
|
len(options.Targets) == 0 || options.ClearRecovery == nil {
|
||
|
|
return nil, errors.New("complete recovery apply options are required")
|
||
|
|
}
|
||
|
|
return &Engine{options: options}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) Run() (runErr error) {
|
||
|
|
defer func() {
|
||
|
|
if runErr != nil {
|
||
|
|
e.recordFailure(runErr)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
if err := requireRegularFile(e.options.PackagePath); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
manifest, _, err := update.VerifyPackage(e.options.PackagePath, e.options.TrustDirectory, e.options.Product, e.options.Profile, e.options.CurrentVersion)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("revalidate recovery package: %w", err)
|
||
|
|
}
|
||
|
|
if manifest.Apply.Capability != update.ApplyCapabilityRecoveryV1 || manifest.Recovery.Capability != update.RecoveryCapabilityOfflineV1 {
|
||
|
|
return errors.New("validated package is not an offline recovery-v1 package")
|
||
|
|
}
|
||
|
|
payloads := orderedPayloads(manifest.Payloads)
|
||
|
|
journal, found, err := e.loadJournal()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, PackageID: manifest.PackageID, Phase: "ready", PayloadIndex: 0, UpdatedAt: e.options.Clock().UTC()}
|
||
|
|
if err := e.persistJournal(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
} else if journal.FormatVersion != JournalFormatVersion || journal.OperationID != e.options.OperationID || journal.PackageID != manifest.PackageID || journal.PayloadIndex < 0 || journal.PayloadIndex > len(payloads) {
|
||
|
|
return errors.New("recovery journal identity or bounds do not match the request")
|
||
|
|
}
|
||
|
|
|
||
|
|
if journal.Phase == "succeeded" {
|
||
|
|
return e.options.ClearRecovery()
|
||
|
|
}
|
||
|
|
start := journal.PayloadIndex
|
||
|
|
if journal.Phase == "verified" {
|
||
|
|
start++
|
||
|
|
}
|
||
|
|
for index := start; index < len(payloads); index++ {
|
||
|
|
payload := payloads[index]
|
||
|
|
target, found := e.options.Targets[payload.TargetPartition]
|
||
|
|
if !found {
|
||
|
|
return fmt.Errorf("target partition %q is not configured", payload.TargetPartition)
|
||
|
|
}
|
||
|
|
if target.MaxBytes > 0 && payload.ExpandedSize > target.MaxBytes {
|
||
|
|
return fmt.Errorf("payload %q exceeds target partition %q", payload.Name, payload.TargetPartition)
|
||
|
|
}
|
||
|
|
journal.PayloadIndex = index
|
||
|
|
journal.PayloadName = payload.Name
|
||
|
|
journal.TargetPartition = payload.TargetPartition
|
||
|
|
journal.Attempt++
|
||
|
|
journal.LastError = ""
|
||
|
|
journal.Phase = "writing"
|
||
|
|
journal.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if err := e.persistJournal(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
e.persistStatus(update.StateApplying, journal, manifest, progress(index, len(payloads), 10), "Writing verified payload to "+payload.TargetPartition)
|
||
|
|
if err := e.after(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := e.writePayload(payload, target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
journal.Phase = "verifying"
|
||
|
|
journal.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if err := e.persistJournal(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
e.persistStatus(update.StateVerifying, journal, manifest, progress(index, len(payloads), 70), "Reading back "+payload.TargetPartition)
|
||
|
|
if err := e.after(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := e.verifyReadback(payload, target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
journal.Phase = "verified"
|
||
|
|
journal.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if err := e.persistJournal(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
e.persistStatus(update.StateApplying, journal, manifest, progress(index, len(payloads), 95), payload.TargetPartition+" readback verified")
|
||
|
|
if err := e.after(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
journal.Phase = "succeeded"
|
||
|
|
journal.PayloadIndex = len(payloads)
|
||
|
|
journal.PayloadName = ""
|
||
|
|
journal.TargetPartition = ""
|
||
|
|
journal.LastError = ""
|
||
|
|
journal.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if err := e.persistJournal(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
e.persistStatus(update.StateSucceeded, journal, manifest, 100, "Recovery payloads verified; restoring normal boot")
|
||
|
|
if err := e.after(journal); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := e.options.ClearRecovery(); err != nil {
|
||
|
|
return fmt.Errorf("clear recovery request: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func orderedPayloads(payloads []update.Payload) []update.Payload {
|
||
|
|
ordered := append([]update.Payload(nil), payloads...)
|
||
|
|
priority := map[string]int{"rootfs": 0, "boot": 1}
|
||
|
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||
|
|
return priority[ordered[i].TargetPartition] < priority[ordered[j].TargetPartition]
|
||
|
|
})
|
||
|
|
return ordered
|
||
|
|
}
|
||
|
|
|
||
|
|
func progress(index, count, within int) int {
|
||
|
|
if count <= 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return (index*100 + within) / count
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) writePayload(payload update.Payload, target Target) error {
|
||
|
|
file, err := e.openTarget(target.Path, os.O_WRONLY)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||
|
|
file.Close()
|
||
|
|
return fmt.Errorf("seek target %s: %w", payload.TargetPartition, err)
|
||
|
|
}
|
||
|
|
streamErr := update.StreamPayload(e.options.PackagePath, payload, file)
|
||
|
|
syncErr := file.Sync()
|
||
|
|
closeErr := file.Close()
|
||
|
|
if streamErr != nil {
|
||
|
|
return streamErr
|
||
|
|
}
|
||
|
|
if syncErr != nil {
|
||
|
|
return fmt.Errorf("sync target %s: %w", payload.TargetPartition, syncErr)
|
||
|
|
}
|
||
|
|
if closeErr != nil {
|
||
|
|
return fmt.Errorf("close target %s: %w", payload.TargetPartition, closeErr)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) verifyReadback(payload update.Payload, target Target) error {
|
||
|
|
file, err := e.openTarget(target.Path, os.O_RDONLY)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
hash := sha256.New()
|
||
|
|
written, err := io.CopyN(hash, file, payload.ExpandedSize)
|
||
|
|
if err != nil || written != payload.ExpandedSize {
|
||
|
|
return fmt.Errorf("read back target %s: read %d of %d bytes: %w", payload.TargetPartition, written, payload.ExpandedSize, err)
|
||
|
|
}
|
||
|
|
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), payload.ExpandedSHA256) {
|
||
|
|
return fmt.Errorf("target %s readback SHA-256 mismatch", payload.TargetPartition)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) openTarget(path string, flag int) (*os.File, error) {
|
||
|
|
info, err := os.Lstat(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("inspect recovery target: %w", err)
|
||
|
|
}
|
||
|
|
block := info.Mode()&os.ModeDevice != 0 && info.Mode()&os.ModeCharDevice == 0
|
||
|
|
if info.Mode()&os.ModeSymlink != 0 || (!block && !(e.options.AllowRegularTarget && info.Mode().IsRegular())) {
|
||
|
|
return nil, fmt.Errorf("recovery target %s is not an approved non-symlink block device", path)
|
||
|
|
}
|
||
|
|
file, err := os.OpenFile(path, flag, 0)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("open recovery target %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return file, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) loadJournal() (Journal, bool, error) {
|
||
|
|
data, err := os.ReadFile(e.options.JournalPath)
|
||
|
|
if os.IsNotExist(err) {
|
||
|
|
return Journal{}, false, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Journal{}, false, fmt.Errorf("read recovery journal: %w", err)
|
||
|
|
}
|
||
|
|
var journal Journal
|
||
|
|
if err := decodeSingleJSON(data, &journal); err != nil {
|
||
|
|
return Journal{}, false, fmt.Errorf("decode recovery journal: %w", err)
|
||
|
|
}
|
||
|
|
return journal, true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) persistJournal(journal Journal) error {
|
||
|
|
data, err := json.MarshalIndent(journal, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := atomicfile.Write(e.options.JournalPath, append(data, '\n'), 0o600); err != nil {
|
||
|
|
return fmt.Errorf("persist recovery journal: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) persistStatus(state string, journal Journal, manifest update.Manifest, percent int, message string) {
|
||
|
|
status := update.Status{FormatVersion: 1, OperationID: e.options.OperationID, Manifest: &manifest}
|
||
|
|
if data, err := os.ReadFile(e.options.StatusPath); err == nil {
|
||
|
|
_ = json.Unmarshal(data, &status)
|
||
|
|
status.Manifest = &manifest
|
||
|
|
}
|
||
|
|
status.State = state
|
||
|
|
status.Message = message
|
||
|
|
status.ProgressPercent = percent
|
||
|
|
status.ApplyEnabled = false
|
||
|
|
status.ApplyDisabledReason = "Recovery apply is in progress"
|
||
|
|
status.RecoveryStep = journal.Phase
|
||
|
|
status.RecoveryAttempt = journal.Attempt
|
||
|
|
status.FailureCode = ""
|
||
|
|
status.FailureReason = ""
|
||
|
|
status.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if data, err := json.MarshalIndent(status, "", " "); err == nil {
|
||
|
|
_ = atomicfile.Write(e.options.StatusPath, append(data, '\n'), recoveryStatusMode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) recordFailure(runErr error) {
|
||
|
|
journal, found, journalErr := e.loadJournal()
|
||
|
|
persistJournal := journalErr == nil
|
||
|
|
if journalErr != nil {
|
||
|
|
journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, Phase: "journal-invalid"}
|
||
|
|
}
|
||
|
|
if journalErr == nil && !found {
|
||
|
|
journal = Journal{FormatVersion: JournalFormatVersion, OperationID: e.options.OperationID, Phase: "failed"}
|
||
|
|
}
|
||
|
|
journal.LastError = boundedError(runErr)
|
||
|
|
journal.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if persistJournal {
|
||
|
|
_ = e.persistJournal(journal)
|
||
|
|
}
|
||
|
|
status := update.Status{FormatVersion: 1, OperationID: e.options.OperationID}
|
||
|
|
if data, err := os.ReadFile(e.options.StatusPath); err == nil {
|
||
|
|
_ = json.Unmarshal(data, &status)
|
||
|
|
}
|
||
|
|
status.State = update.StateFailed
|
||
|
|
status.Message = "Recovery updater stopped safely; the recovery request remains set"
|
||
|
|
status.ApplyEnabled = false
|
||
|
|
status.ApplyDisabledReason = "Recovery service action is required"
|
||
|
|
status.RecoveryStep = journal.Phase
|
||
|
|
status.RecoveryAttempt = journal.Attempt
|
||
|
|
status.FailureCode = "recovery_apply_failed"
|
||
|
|
status.FailureReason = journal.LastError
|
||
|
|
status.RecoveryInstructions = "Keep both power inputs connected, preserve userdata and misc, and follow the approved product service recovery procedure. The final product does not expose the development COM5 console. A subsequent recovery boot may safely retry the recorded payload step."
|
||
|
|
status.UpdatedAt = e.options.Clock().UTC()
|
||
|
|
if data, err := json.MarshalIndent(status, "", " "); err == nil {
|
||
|
|
_ = atomicfile.Write(e.options.StatusPath, append(data, '\n'), recoveryStatusMode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Engine) after(journal Journal) error {
|
||
|
|
if e.options.AfterTransition == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return e.options.AfterTransition(Transition{Phase: journal.Phase, PayloadIndex: journal.PayloadIndex, PayloadName: journal.PayloadName, TargetPartition: journal.TargetPartition})
|
||
|
|
}
|
||
|
|
|
||
|
|
func requireRegularFile(path string) error {
|
||
|
|
info, err := os.Lstat(path)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("inspect validated package: %w", err)
|
||
|
|
}
|
||
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||
|
|
return errors.New("validated package is not a regular non-symlink file")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeSingleJSON(data []byte, target any) error {
|
||
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
if err := decoder.Decode(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var extra any
|
||
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||
|
|
return errors.New("must contain exactly one JSON value")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func boundedError(err error) string {
|
||
|
|
value := strings.TrimSpace(err.Error())
|
||
|
|
if len(value) > 512 {
|
||
|
|
return value[:512]
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
func JournalPath(updateRoot, operationID string) string {
|
||
|
|
return filepath.Join(updateRoot, "journal", "recovery-"+operationID+".json")
|
||
|
|
}
|