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

// 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")
}