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.
190 lines
5.7 KiB
190 lines
5.7 KiB
|
2 months ago
|
//go:build linux
|
||
|
|
|
||
|
|
// tera-recovery-updater is the static PID 1 for authenticated offline updates.
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"golang.org/x/sys/unix"
|
||
|
|
|
||
|
|
"tera-clone/services/tera-api/internal/update"
|
||
|
|
"tera-clone/services/tera-api/internal/update/recoveryapply"
|
||
|
|
"tera-clone/services/tera-api/internal/update/recoveryrequest"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
updateRoot = "/userdata/update"
|
||
|
|
trustDirectory = "/etc/tera/update-trust"
|
||
|
|
product = "tera-core-x48-pro"
|
||
|
|
hardwareProfile = "fet3568j-c"
|
||
|
|
sectorBytes = 512
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
mustMount("devtmpfs", "/dev", "devtmpfs", unix.MS_NOSUID, "mode=0755")
|
||
|
|
mustMount("proc", "/proc", "proc", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
|
||
|
|
mustMount("sysfs", "/sys", "sysfs", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
|
||
|
|
// ttyS2 is a best-effort development trace only. The final product does not
|
||
|
|
// expose COM5; durable status and journal files in userdata are authoritative.
|
||
|
|
console, err := os.OpenFile("/dev/ttyS2", os.O_WRONLY, 0)
|
||
|
|
if err != nil {
|
||
|
|
console = os.Stdout
|
||
|
|
}
|
||
|
|
log := func(format string, values ...any) {
|
||
|
|
fmt.Fprintf(console, "[tera-recovery-updater] "+format+"\r\n", values...)
|
||
|
|
}
|
||
|
|
log("START authenticated offline recovery updater")
|
||
|
|
|
||
|
|
cmdline, err := os.ReadFile("/proc/cmdline")
|
||
|
|
if err != nil || !containsWord(string(cmdline), "tera.recovery=update") || !containsWord(string(cmdline), "rdinit=/init") {
|
||
|
|
hold(log, "FAIL unexpected recovery kernel command line: %q (%v)", strings.TrimSpace(string(cmdline)), err)
|
||
|
|
}
|
||
|
|
for _, partition := range recoveryPartitionContract {
|
||
|
|
if err := requirePartName(partition.UEventPath, partition.Name); err != nil {
|
||
|
|
hold(log, "FAIL partition identity %s: %v", partition.Name, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
misc, err := os.OpenFile(miscDevice, os.O_RDWR, 0)
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL open misc: %v", err)
|
||
|
|
}
|
||
|
|
message, err := recoveryrequest.ReadAt(misc)
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL read authenticated recovery request: %v", err)
|
||
|
|
}
|
||
|
|
log("PASS recovery request operation=%s", message.OperationID)
|
||
|
|
if err := unix.Mount(userdataDevice, "/userdata", "ext4", unix.MS_NODEV|unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NOATIME, userdataMountData); err != nil {
|
||
|
|
hold(log, "FAIL mount userdata: %v", err)
|
||
|
|
}
|
||
|
|
bootBytes, err := blockBytes(bootSizePath)
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL boot partition size: %v", err)
|
||
|
|
}
|
||
|
|
rootfsBytes, err := blockBytes(rootfsSizePath)
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL rootfs partition size: %v", err)
|
||
|
|
}
|
||
|
|
packagePath := updateRoot + "/validated/" + message.OperationID + ".pkg"
|
||
|
|
statusPath := updateRoot + "/journal/status.json"
|
||
|
|
deviceVersion, err := readDeviceVersion(statusPath, message.OperationID)
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL durable device-version binding: %v", err)
|
||
|
|
}
|
||
|
|
engine, err := recoveryapply.New(recoveryapply.Options{
|
||
|
|
OperationID: message.OperationID,
|
||
|
|
PackagePath: packagePath,
|
||
|
|
JournalPath: recoveryapply.JournalPath(updateRoot, message.OperationID),
|
||
|
|
StatusPath: statusPath,
|
||
|
|
TrustDirectory: trustDirectory,
|
||
|
|
Product: product,
|
||
|
|
Profile: hardwareProfile,
|
||
|
|
CurrentVersion: deviceVersion,
|
||
|
|
Targets: map[string]recoveryapply.Target{
|
||
|
|
"rootfs": {Path: rootfsDevice, MaxBytes: rootfsBytes},
|
||
|
|
"boot": {Path: bootDevice, MaxBytes: bootBytes},
|
||
|
|
},
|
||
|
|
ClearRecovery: func() error { return recoveryrequest.WriteAt(misc, recoveryrequest.Clear()) },
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
hold(log, "FAIL recovery engine setup: %v", err)
|
||
|
|
}
|
||
|
|
if err := engine.Run(); err != nil {
|
||
|
|
hold(log, "FAIL recovery apply: %v", err)
|
||
|
|
}
|
||
|
|
log("PASS all selected payloads and readbacks verified; recovery request cleared")
|
||
|
|
_ = misc.Close()
|
||
|
|
unix.Sync()
|
||
|
|
log("REBOOT normal boot")
|
||
|
|
if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
|
||
|
|
hold(log, "FAIL reboot normal system: %v", err)
|
||
|
|
}
|
||
|
|
for {
|
||
|
|
time.Sleep(time.Hour)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustMount(source, target, filesystem string, flags uintptr, data string) {
|
||
|
|
if err := unix.Mount(source, target, filesystem, flags, data); err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "mount %s: %v\n", target, err)
|
||
|
|
for {
|
||
|
|
time.Sleep(time.Hour)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsWord(value, wanted string) bool {
|
||
|
|
for _, word := range strings.Fields(value) {
|
||
|
|
if word == wanted {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func requirePartName(path, wanted string) error {
|
||
|
|
file, err := os.Open(path)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
scanner := bufio.NewScanner(io.LimitReader(file, 4096))
|
||
|
|
for scanner.Scan() {
|
||
|
|
if scanner.Text() == "PARTNAME="+wanted {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := scanner.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return fmt.Errorf("expected PARTNAME=%s", wanted)
|
||
|
|
}
|
||
|
|
|
||
|
|
func blockBytes(path string) (int64, error) {
|
||
|
|
data, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
sectors, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||
|
|
if err != nil || sectors <= 0 || sectors > (1<<62)/sectorBytes {
|
||
|
|
return 0, errors.New("invalid block-sector count")
|
||
|
|
}
|
||
|
|
return sectors * sectorBytes, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func readDeviceVersion(path, operationID string) (string, error) {
|
||
|
|
file, err := os.Open(path)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
var status update.Status
|
||
|
|
decoder := json.NewDecoder(io.LimitReader(file, 1024*1024))
|
||
|
|
if err := decoder.Decode(&status); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
var trailing any
|
||
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||
|
|
return "", errors.New("status journal must contain exactly one JSON value")
|
||
|
|
}
|
||
|
|
if status.OperationID != operationID || strings.TrimSpace(status.DeviceVersion) == "" {
|
||
|
|
return "", errors.New("status journal operation or device version does not match recovery request")
|
||
|
|
}
|
||
|
|
return status.DeviceVersion, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func hold(log func(string, ...any), format string, values ...any) {
|
||
|
|
log(format, values...)
|
||
|
|
for {
|
||
|
|
time.Sleep(time.Hour)
|
||
|
|
}
|
||
|
|
}
|