Files
2026-07-03 08:12:18 +02:00

518 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gokrazy
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"hash"
"hash/crc32"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
"github.com/gokrazy/internal/deviceconfig"
"github.com/gokrazy/internal/fat"
"github.com/gokrazy/internal/rootdev"
"github.com/google/renameio/v2"
)
var rootRe = regexp.MustCompile(`root=[^ ]+`)
func readCmdline() ([]byte, error) {
f, err := os.OpenFile(rootdev.Partition(rootdev.Boot), os.O_RDWR, 0600)
if err != nil {
return nil, err
}
defer f.Close()
rd, err := fat.NewReader(f)
if err != nil {
return nil, err
}
offset, length, err := rd.Extents("/cmdline.txt")
if err != nil {
return nil, err
}
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return nil, err
}
b := make([]byte, length)
if _, err := f.Read(b); err != nil {
return nil, err
}
return b, nil
}
func modifyCmdlineFile(f *os.File, offset, length int64, replace func([]byte) []byte) error {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return err
}
b := make([]byte, length)
if _, err := f.Read(b); err != nil {
return err
}
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return err
}
rep := replace(b)
if pad := len(b) - len(rep); pad > 0 {
// The file content length can shrink when switching from PARTUUID= (the
// default) to /dev/mmcblk0p[23], on an older gokrazy installation.
// Because we overwrite the file in place and have no means to truncate
// it to a smaller length, we pad the command line with spaces instead.
// Note that we need to insert spaces before the trailing newline,
// otherwise the system wont boot:
rep = bytes.ReplaceAll(rep,
[]byte{'\n'},
append(bytes.Repeat([]byte{' '}, pad), '\n'))
}
if _, err := f.Write(rep); err != nil {
return err
}
return nil
}
func modifyCmdline(replace func([]byte) []byte) error {
f, err := os.OpenFile(rootdev.Partition(rootdev.Boot), os.O_RDWR, 0600)
if err != nil {
return err
}
defer f.Close()
rd, err := fat.NewReader(f)
if err != nil {
return err
}
offset, length, err := rd.Extents("/cmdline.txt")
if err != nil {
return err
}
if err := modifyCmdlineFile(f, offset, length, replace); err != nil {
return err
}
offset, length, err = rd.Extents("/loader/entries/gokrazy.conf")
if err != nil {
// This file might not be present yet when using a recent gokrazy
// installation with an older gokr-packer.
log.Printf("updating systemd-boot config: %v", err)
return nil
}
if err := modifyCmdlineFile(f, offset, length, replace); err != nil {
return err
}
return f.Close()
}
func switchRootPartition(newRootPartition int) error {
return modifyCmdline(func(b []byte) []byte {
return rootRe.ReplaceAllLiteral(b, []byte("root="+rootdev.PartitionCmdline(newRootPartition)))
})
}
func enableTestboot() error {
return modifyCmdline(func(b []byte) []byte {
return bytes.ReplaceAll(b,
[]byte{'\n'},
[]byte(" gokrazy.try_boot_inactive=1\n"))
})
}
func streamRequestTo(path string, offset int64, r io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY, 0600)
if err != nil {
return err
}
defer f.Close()
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return err
}
}
if _, err := io.Copy(f, r); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
return f.Close()
}
// createFile is different from streamRequestTo in that it creates new files
// (instead of just overwriting existing ones) and not syncing to disk, because
// it is intended for the /uploadtemp handler.
func createFile(path string, r io.Reader) error {
f, err := renameio.TempFile("", path)
if err != nil {
return err
}
defer f.Cleanup()
if _, err := io.Copy(f, r); err != nil {
return err
}
return f.CloseAtomicallyReplace()
}
func nonConcurrentUpdateHandler(dest string) func(http.ResponseWriter, *http.Request) {
return nonConcurrentLimitedUpdateHandler(dest, 0, 0)
}
func nonConcurrentLimitedUpdateHandler(dest string, offset int64, maxLength int64) func(http.ResponseWriter, *http.Request) {
var mu sync.Mutex
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "expected a PUT request", http.StatusBadRequest)
return
}
mu.Lock()
defer mu.Unlock()
var hash hash.Hash
switch r.Header.Get("X-Gokrazy-Update-Hash") {
case "crc32":
hash = crc32.NewIEEE()
default:
hash = sha256.New()
}
var reader io.Reader = r.Body
if maxLength > 0 {
reader = io.LimitReader(reader, maxLength)
}
if err := streamRequestTo(dest, offset, io.TeeReader(reader, hash)); err != nil {
log.Printf("updating %q failed: %v", dest, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%x", hash.Sum(nil))
}
}
func nonConcurrentSwitchHandler(newRootPartition int) func(http.ResponseWriter, *http.Request) {
var mu sync.Mutex
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "expected a POST request", http.StatusBadRequest)
return
}
mu.Lock()
defer mu.Unlock()
if err := switchRootPartition(newRootPartition); err != nil {
log.Printf("switching root partition to %d failed: %v", newRootPartition, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func nonConcurrentTestbootHandler(newRootPartition int) func(http.ResponseWriter, *http.Request) {
var mu sync.Mutex
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "expected a POST request", http.StatusBadRequest)
return
}
mu.Lock()
defer mu.Unlock()
if err := enableTestboot(); err != nil {
log.Printf("enabling test-boot of new root partition %d failed: %v", newRootPartition, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
type extraUpdateHandler struct {
name string
device string
offset int64
maxLength int64
}
var extraUpdateHandlers []*extraUpdateHandler
func setupDeviceSpecifics() {
modelName := Model()
cfg, ok := deviceconfig.DeviceConfigs[modelName]
if !ok {
return
}
log.Printf("setting up device-specific update handlers for %q", modelName)
for _, rootDevFile := range cfg.RootDeviceFiles {
extraUpdateHandlers = append(extraUpdateHandlers, &extraUpdateHandler{
name: rootDevFile.Name,
device: rootdev.BlockDevice(),
offset: rootDevFile.Offset,
maxLength: rootDevFile.MaxLength,
})
}
}
// Allow for slow VM shutdown of 90 seconds, and then some
const defaultSignalDelay = 100 * time.Second
func initUpdate() error {
// The /update/features handler is used for negotiation of individual
// feature support (e.g. PARTUUID= support) between the packer and update
// target.
gpt := ""
if strings.Contains(rootdev.PARTUUID(), "-") {
gpt = ",gpt"
}
featureStr := fmt.Sprintf("partuuid,updatehash%s,", gpt)
http.HandleFunc("/update/features", func(w http.ResponseWriter, r *http.Request) {
if !jsonRequested(r) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, featureStr)
return
}
// This response contains all information that the
// github.com/gokrazy/updater package needs to obtain from the
// target device, in one single go.
b, err := json.Marshal(struct {
Features string `json:"features"`
EEPROM *eepromVersion `json:"EEPROM"`
}{
Features: featureStr,
EEPROM: lastInstalledEepromVersion,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(b)
})
inactiveRootPartition := rootdev.InactiveRootPartition()
log.Printf("update handler will be called with inactiveRootPartition = %v", inactiveRootPartition)
http.HandleFunc("/update/mbr", nonConcurrentUpdateHandler(rootdev.BlockDevice()))
http.HandleFunc("/update/root", nonConcurrentUpdateHandler(rootdev.Partition(inactiveRootPartition)))
http.HandleFunc("/update/switch", nonConcurrentSwitchHandler(inactiveRootPartition))
http.HandleFunc("/update/testboot", nonConcurrentTestbootHandler(inactiveRootPartition))
for _, extraUpdateHandler := range extraUpdateHandlers {
http.HandleFunc(fmt.Sprintf("/update/device-specific/%s", extraUpdateHandler.name), nonConcurrentLimitedUpdateHandler(extraUpdateHandler.device, extraUpdateHandler.offset, extraUpdateHandler.maxLength))
}
// bakery updates only the boot partition, which would reset the active root
// partition to 2.
updateHandler := nonConcurrentUpdateHandler(rootdev.Partition(rootdev.Boot))
http.HandleFunc("/update/boot", updateHandler)
http.HandleFunc("/update/bootonly", func(w http.ResponseWriter, r *http.Request) {
updateHandler(w, r)
if err := switchRootPartition(rootdev.ActiveRootPartition()); err != nil {
log.Printf("switching root partition to %d failed: %v", rootdev.ActiveRootPartition(), err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
http.HandleFunc("/reboot", func(w http.ResponseWriter, r *http.Request) {
powerHandler(w, r, "reboot", "Rebooting", func() error {
return reboot(r.FormValue("kexec") != "off")
})
})
http.HandleFunc("/poweroff", func(w http.ResponseWriter, r *http.Request) {
powerHandler(w, r, "power off", "Powering off", func() error {
return unix.Reboot(unix.LINUX_REBOOT_CMD_POWER_OFF)
})
})
http.HandleFunc("/uploadtemp/", uploadTemp)
http.HandleFunc("/divert", divert)
return nil
}
func powerHandler(w http.ResponseWriter, r *http.Request, operationName, powerMsg string, powerFunc func() error) {
if r.Method != http.MethodPost {
http.Error(w, "expected a POST request", http.StatusBadRequest)
return
}
signalDelay := defaultSignalDelay
if s := r.FormValue("wait_per_signal"); s != "" {
var err error
signalDelay, err = time.ParseDuration(s)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
rc := http.NewResponseController(w)
if r.FormValue("async") == "true" {
log.Printf("Asynchronous %s requested", operationName)
go func() {
start := time.Now()
killSupervisedServicesAndUmountPerm(signalDelay)
log.Printf("All services killed in %s\n", time.Since(start))
}()
} else {
log.Printf("Synchronous %s requested", operationName)
start := time.Now()
killSupervisedServicesAndUmountPerm(signalDelay)
fmt.Fprintf(w, "All services killed in %s\n", time.Since(start))
}
log.Println(powerMsg)
w.Write([]byte(powerMsg + "..." + "\n"))
rc.Flush()
go func() {
time.Sleep(time.Second) // give the http response some time to be sent
if err := powerFunc(); err != nil {
log.Printf("could not %s: %v", operationName, err)
}
}()
}
func killSupervisedServicesAndUmountPerm(signalDelay time.Duration) {
killSupervisedServices(signalDelay)
log.Println("Unmounting /perm")
if err := syscall.Unmount("/perm", unix.MNT_FORCE); err != nil {
log.Printf("unmounting /perm failed: %v", err)
}
}
func uploadTemp(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "expected a PUT request", http.StatusBadRequest)
return
}
dest := strings.TrimPrefix(r.URL.Path, "/uploadtemp/")
log.Printf("uploadtemp dest=%q", dest)
if strings.Contains(dest, "/") {
// relative path in the temp directory
dest = filepath.Join(os.TempDir(), dest)
if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
log.Printf("updating %q failed: %v", dest, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// not relative, create a subdirectory underneath the temp directory
tmpdir, err := os.MkdirTemp("", "uploadtemp")
if err != nil {
log.Printf("updating %q failed: %v", dest, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// tmpdir will intentionally not be deleted, as the caller of
// /uploadtemp will use the uploaded file afterwards.
dest = filepath.Join(tmpdir, dest)
}
if err := createFile(dest, r.Body); err != nil {
log.Printf("updating %q failed: %v", dest, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(struct {
Dest string `json:"dest"`
}{
Dest: dest,
})
if err != nil {
log.Print(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := w.Write(b); err != nil {
log.Print(err)
}
}
type divertRequest struct {
Path string `json:"Path"`
Diversion string `json:"Diversion"`
Flags []string `json:"Flags"`
}
func divert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "expected a POST request", http.StatusBadRequest)
return
}
var req divertRequest
if r.Header.Get("Content-Type") == "application/json" {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "couldn't read request body", http.StatusBadRequest)
return
}
err = json.Unmarshal(body, &req)
if err != nil {
http.Error(w, "couldn't unmarshal request body", http.StatusBadRequest)
return
}
} else {
req.Diversion = r.FormValue("diversion")
req.Path = r.FormValue("path")
}
if req.Diversion == "" {
http.Error(w, "diversion parameter is not set", http.StatusBadRequest)
return
}
svc := findSvc(req.Path)
newService := svc == nil
if newService {
log.Printf("adding new service in-memory to make diversion work")
cmd := exec.Command(req.Path, req.Flags...)
svc = NewService(cmd).s
}
// Ensure diversion binary is executable (/uploadtemp creates regular,
// non-executable files).
diversion := filepath.Join(os.TempDir(), req.Diversion)
if err := os.Chmod(diversion, 0755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
svc.setDiversion(diversion)
cmd := svc.Cmd()
cmd.Args = append([]string{cmd.Args[0]}, req.Flags...)
svc.setCmd(cmd)
if newService {
services.Lock()
services.S = append(services.S, svc)
services.Unlock()
go supervise(svc)
}
if err := restart(svc, syscall.SIGTERM); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}